From a0ec3d99e4e0ff3ffd1c4494c59360d2937e71ad Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 08:27:35 -0400 Subject: [PATCH 01/13] feat(profile): other-user profile with overflow menu and pinned action --- .../Controllers/ConversationController.swift | 6 + .../Conversation/StartChattingSheet.swift | 12 +- .../Screens/Profile/ChatMuteStatusLabel.swift | 6 +- .../Screens/Profile/ProfileMenuItems.swift | 53 +++ .../Screens/Profile/ProfilePinnedAction.swift | 38 ++ .../Screens/Profile/ProfileStatusChip.swift | 24 ++ .../Screens/Profile/UserProfileScreen.swift | 390 +++++++++++------- .../Chat/MentionDestinationTests.swift | 5 - FlipcashTests/ProfileMenuItemsTests.swift | 41 ++ FlipcashTests/ProfilePinnedActionTests.swift | 55 +++ .../UserProfileChatActionsTests.swift | 36 +- .../Smoke/BlockUnblockSmokeTests.swift | 3 +- 12 files changed, 473 insertions(+), 196 deletions(-) create mode 100644 Flipcash/Core/Screens/Profile/ProfileMenuItems.swift create mode 100644 Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift create mode 100644 Flipcash/Core/Screens/Profile/ProfileStatusChip.swift create mode 100644 FlipcashTests/ProfileMenuItemsTests.swift create mode 100644 FlipcashTests/ProfilePinnedActionTests.swift 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/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/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index 240d5e337..2893a134c 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -103,10 +103,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/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift new file mode 100644 index 000000000..0bc421a3c --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -0,0 +1,53 @@ +// +// ProfileMenuItems.swift +// Flipcash +// + +nonisolated enum ProfileMenuItem: Equatable { + case mute + case unmute + case report + case block + case unblock + + var title: String { + switch self { + case .mute: return "Mute" + case .unmute: return "Unmute" + case .report: return "Report" + case .block: return "Block" + case .unblock: return "Unblock" + } + } + + var systemImage: String { + switch self { + case .mute: return "bell.slash" + case .unmute: return "bell" + 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, .unmute, .unblock: return false + } + } +} + +/// The entries of another user's overflow menu. +nonisolated enum ProfileMenuItems { + + /// Mute and Unmute need a DM to silence and are hidden while blocked, since the DM is hidden too. + static func resolve(isBlocked: Bool, hasDM: Bool, isMuted: Bool) -> [ProfileMenuItem] { + if isBlocked { return [.report, .unblock] } + var items: [ProfileMenuItem] = [] + if hasDM { items.append(isMuted ? .unmute : .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/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..64001e347 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -9,12 +9,11 @@ import SwiftUI 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 +25,6 @@ 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 - ), model: UserProfileViewModel( userID: userID, flipClient: sessionContainer.flipClient, @@ -52,154 +42,261 @@ 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 @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? + /// Set when the payment lands; promoted to `awaitingChat` once the sheet has dismissed. + @State private var didPayToStartChatting = false + /// True from the sheet's dismissal until the chat's record arrives over the feed, so the push + /// happens once and only for a chat this screen just paid for. + @State private var awaitingChat = false + + init(model: UserProfileViewModel) { _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 isMuted: Bool { + guard let dmID else { return false } + return conversationController.conversation(withID: dmID)?.isMuted() ?? false + } + + private var menuItems: [ProfileMenuItem] { + guard !isSelf else { return [] } + return ProfileMenuItems.resolve(isBlocked: isBlocked, hasDM: dmID != nil, isMuted: isMuted) + } + + 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) - - 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) - } + ScrollView { + VStack(spacing: 16) { + ProfileHeaderView( + userID: model.userID, + displayName: model.displayName, + handle: model.handle, + bio: model.bio, + avatarData: model.imageData, + avatarBlurhash: model.blurhash, + coverPicture: model.coverPicture, + customization: model.customization, + statusChip: statusChip, + bannerActions: { EmptyView() }, + underHandle: { EmptyView() } + ) + + ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt) + } + .padding(.horizontal, 20) + } + } + .safeAreaInset(edge: .bottom) { + pinnedButton + } + .navigationTitle("") + .toolbarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .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: { + guard didPayToStartChatting else { return } + didPayToStartChatting = false + awaitingChat = true + }) { request in + StartChattingSheet(target: request.target, fee: request.fee) { + didPayToStartChatting = true + } + } + .onChange(of: dmID) { _, id in + guard awaitingChat, let id else { return } + awaitingChat = false + 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: awaitingChat) { + guard awaitingChat else { return } + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled else { return } + awaitingChat = false + } + .task { await model.loadProfile() } + } - if let joined = model.joinedText { - Text(joined) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - } - } + // MARK: - Header chip - - // 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") - } + private var statusChip: AnyView? { + if isBlocked { + return AnyView( + ProfileStatusChip(systemImage: "nosign", text: "Blocked", tint: .warning, fill: .warningSecondary) + .accessibilityIdentifier("profile-blocked-badge") + ) + } + if let dmID { + return AnyView(ChatMuteStatusLabel(conversationID: dmID)) + } + return nil + } - ProfileActionButton(title: "Share") { - Image.asset(.shareOS) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - } action: { - model.share() - } - .accessibilityIdentifier("profile-share") - } - .padding(.top, 25) + // MARK: - Toolbar - - // 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) - } + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + model.share() + } label: { + Image.asset(.shareOS) + .renderingMode(.template) + } + .accessibilityIdentifier("profile-share") + } - 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) + if !menuItems.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + 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) + } + .accessibilityIdentifier("profile-overflow") + } + } + } - ReportRow(target: .user(model.userID), insets: rowInsets) + private func perform(_ item: ProfileMenuItem) { + switch item { + case .mute: + isPickingMuteDuration = true + case .unmute: + guard let dmID else { return } + Task { + do { + try await conversationController.unmute(conversationID: dmID) + } catch { + ErrorReporting.captureError(error, reason: "Failed to unmute chat from profile") + } + } + case .report: + isReporting = true + case .block: + dialogItem = blockDialog() + case .unblock: + Task { await model.unblock() } + } + } - Row(insets: rowInsets) { - Image(systemName: "nosign") - .frame(minWidth: 45) - Text("Block") - .foregroundStyle(.textMain) - Spacer() - } action: { - dialogItem = blockDialog() - } - .accessibilityIdentifier("chat-block") - } - .font(.appDisplayXS) + // MARK: - Pinned action - + + @ViewBuilder + private var pinnedButton: some View { + if let title = pinnedAction.title { + VStack(spacing: 8) { + Button(title) { + tapPinned() } + .buttonStyle(.filled) + .accessibilityIdentifier("profile-pinned-action") - Spacer() + if showsE2eeFooter { + E2eeFooter(kind: .dm) + } } .padding(.horizontal, 20) + .padding(.bottom, 8) + } else if showsE2eeFooter { + E2eeFooter(kind: .dm) } - .safeAreaInset(edge: .bottom) { - if showsE2eeFooter { - E2eeFooter(kind: .dm) + } + + private func tapPinned() { + switch pinnedAction { + case .none: + break + case .unblock: + Task { await model.unblock() } + case .openChat(let id): + router.push(.tipConversation(id)) + case .startChattingUnpriced: + router.presentSendAmount(sendTarget) + case .startChatting(let fee): + let rate = ratesController.rateForBalanceCurrency() + if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .chat, context: .sendTips) { + session.dialogItem = dialog + return } + startChattingRequest = StartChattingRequest(target: sendTarget, fee: fee) } - .navigationTitle("") - .toolbarTitleDisplayMode(.inline) - .dialog(item: $dialogItem) - .task { await model.loadProfile() } } /// 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,25 +321,6 @@ 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 } - 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 - } - } - /// Whether the profile was fetched and cached just before the screen opened, so the screen /// reads the cache instead of fetching again. A link is looked up before it navigates. var arrivesFetched: Bool { @@ -273,7 +351,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,9 +424,11 @@ 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 } /// Opens the share sheet on this person's public link, the one their own You tab shares. @@ -353,6 +437,16 @@ final class UserProfileViewModel { 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/ProfileMenuItemsTests.swift b/FlipcashTests/ProfileMenuItemsTests.swift new file mode 100644 index 000000000..944e32b80 --- /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 that is audible offers Mute, Report and Block") + func dm_unmuted() { + #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: true, isMuted: false) == [.mute, .report, .block]) + } + + @Test("A muted DM offers Unmute in Mute's place") + func dm_muted() { + #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: true, isMuted: true) == [.unmute, .report, .block]) + } + + @Test("Without a DM there is nothing to mute") + func noDM() { + #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: false, isMuted: 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, isMuted: false) == [.report, .unblock]) + } + + @Test("Report, Block and Unblock are destructive; Unblock and the mutes are not") + func destructive() { + #expect(ProfileMenuItem.report.isDestructive) + #expect(ProfileMenuItem.block.isDestructive) + #expect(!ProfileMenuItem.unblock.isDestructive) + #expect(!ProfileMenuItem.mute.isDestructive) + #expect(!ProfileMenuItem.unmute.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/UserProfileChatActionsTests.swift b/FlipcashTests/UserProfileChatActionsTests.swift index 2f82fa4d3..52f4c4a23 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) 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 From 72d550450f9cda687da38bddadc1b5c25d0ba219 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 08:58:28 -0400 Subject: [PATCH 02/13] fix(profile): open chat reliably after paying and align overflow actions Push the new chat when the DM record lands before the start-chatting sheet dismisses, and only when the profile is still in front. Open Chat from a DM returns to it instead of pushing a copy. Unmute from the menu reports errors and analytics like the mute sheet, Start Chatting runs the give-cash gate in both branches, and the toolbar buttons get accessibility labels. --- Flipcash/Core/Navigation/AppRouter.swift | 13 +++ .../Screens/Profile/PostPaymentChat.swift | 48 +++++++++++ .../Screens/Profile/ProfileMenuItems.swift | 3 + .../Screens/Profile/UserProfileScreen.swift | 86 ++++++++++++++----- FlipcashTests/PostPaymentChatTests.swift | 76 ++++++++++++++++ FlipcashTests/ProfileMenuItemsTests.swift | 2 +- .../UserProfileChatActionsTests.swift | 8 ++ 7 files changed, 212 insertions(+), 24 deletions(-) create mode 100644 Flipcash/Core/Screens/Profile/PostPaymentChat.swift create mode 100644 FlipcashTests/PostPaymentChatTests.swift 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/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/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift index 0bc421a3c..490fb0430 100644 --- a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -3,6 +3,7 @@ // Flipcash // +/// An entry in another user's overflow menu. nonisolated enum ProfileMenuItem: Equatable { case mute case unmute @@ -10,6 +11,7 @@ nonisolated enum ProfileMenuItem: Equatable { case block case unblock + /// The entry's label. var title: String { switch self { case .mute: return "Mute" @@ -20,6 +22,7 @@ nonisolated enum ProfileMenuItem: Equatable { } } + /// The SF Symbol drawn beside the label. var systemImage: String { switch self { case .mute: return "bell.slash" diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 64001e347..d2ac09caf 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -25,6 +25,7 @@ struct UserProfileScreen: View { var body: some View { let seed = sessionContainer.conversationController.counterpartSeed(forUserID: userID) UserProfileContent( + origin: origin, model: UserProfileViewModel( userID: userID, flipClient: sessionContainer.flipClient, @@ -49,18 +50,19 @@ private struct UserProfileContent: View { @Environment(ConversationController.self) private var conversationController @Environment(BlocklistController.self) private var blocklistController + let origin: UserProfileOrigin + @State private var model: UserProfileViewModel @State private var dialogItem: DialogItem? @State private var isPickingMuteDuration = false @State private var isReporting = false @State private var startChattingRequest: StartChattingRequest? - /// Set when the payment lands; promoted to `awaitingChat` once the sheet has dismissed. - @State private var didPayToStartChatting = false - /// True from the sheet's dismissal until the chat's record arrives over the feed, so the push - /// happens once and only for a chat this screen just paid for. - @State private var awaitingChat = false + @State private var postPayment = PostPaymentChat() + /// Where this screen sits in its stack, recorded when it first appears. + @State private var position: AppRouter.StackPosition? - init(model: UserProfileViewModel) { + init(origin: UserProfileOrigin, model: UserProfileViewModel) { + self.origin = origin _model = State(initialValue: model) } @@ -106,6 +108,12 @@ private struct UserProfileContent: View { return ProfileMenuItems.resolve(isBlocked: isBlocked, hasDM: dmID != nil, isMuted: isMuted) } + /// 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)) } @@ -153,26 +161,29 @@ private struct UserProfileContent: View { } } .sheet(item: $startChattingRequest, onDismiss: { - guard didPayToStartChatting else { return } - didPayToStartChatting = false - awaitingChat = true + if let id = postPayment.sheetDismissed(dmID: dmID, isScreenInFront: isScreenInFront) { + router.push(.tipConversation(id)) + } }) { request in StartChattingSheet(target: request.target, fee: request.fee) { - didPayToStartChatting = true + postPayment.paymentSucceeded() } } .onChange(of: dmID) { _, id in - guard awaitingChat, let id else { return } - awaitingChat = false - router.push(.tipConversation(id)) + 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: awaitingChat) { - guard awaitingChat else { return } + .task(id: postPayment.isAwaitingChat) { + guard postPayment.isAwaitingChat else { return } try? await Task.sleep(for: .seconds(10)) guard !Task.isCancelled else { return } - awaitingChat = false + postPayment.gaveUp() + } + .onAppear { + if position == nil { position = router.positionOfTopmost() } } .task { await model.loadProfile() } } @@ -203,6 +214,7 @@ private struct UserProfileContent: View { Image.asset(.shareOS) .renderingMode(.template) } + .accessibilityLabel("Share profile") .accessibilityIdentifier("profile-share") } @@ -219,6 +231,7 @@ private struct UserProfileContent: View { } label: { Image.system(.ellipsis) } + .accessibilityLabel("More") .accessibilityIdentifier("profile-overflow") } } @@ -230,11 +243,18 @@ private struct UserProfileContent: View { isPickingMuteDuration = true case .unmute: guard let dmID else { return } + let chatType = conversationController.conversation(withID: dmID)?.type Task { do { try await conversationController.unmute(conversationID: dmID) + Analytics.chatUnmuted(chatType: chatType, error: nil) } catch { - ErrorReporting.captureError(error, reason: "Failed to unmute chat from profile") + Analytics.chatUnmuted(chatType: chatType, error: error) + session.dialogItem = .error( + title: "Something Went Wrong", + subtitle: "We were unable to unmute this chat. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to unmute chat") } } case .report: @@ -276,19 +296,30 @@ private struct UserProfileContent: View { case .unblock: Task { await model.unblock() } case .openChat(let id): - router.push(.tipConversation(id)) + if origin.returnsToExistingDM { + router.popTopmost() + } else { + router.push(.tipConversation(id)) + } case .startChattingUnpriced: + guard passesGiveCashGate() else { return } router.presentSendAmount(sendTarget) case .startChatting(let fee): - let rate = ratesController.rateForBalanceCurrency() - if let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .chat, context: .sendTips) { - session.dialogItem = dialog - return - } + 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 dmID, let conversation = conversationController.conversation(withID: dmID) else { @@ -321,6 +352,15 @@ nonisolated enum UserProfileOrigin: Hashable { /// A `flipcash.com/` or `flipcash.com/` link opened into the app. case deeplink + /// 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 true + case .groupMember, .mention, .deeplink: return false + } + } + /// Whether the profile was fetched and cached just before the screen opened, so the screen /// reads the cache instead of fetching again. A link is looked up before it navigates. var arrivesFetched: Bool { 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/ProfileMenuItemsTests.swift b/FlipcashTests/ProfileMenuItemsTests.swift index 944e32b80..e36ab099a 100644 --- a/FlipcashTests/ProfileMenuItemsTests.swift +++ b/FlipcashTests/ProfileMenuItemsTests.swift @@ -30,7 +30,7 @@ struct ProfileMenuItemsTests { #expect(ProfileMenuItems.resolve(isBlocked: true, hasDM: hasDM, isMuted: false) == [.report, .unblock]) } - @Test("Report, Block and Unblock are destructive; Unblock and the mutes are not") + @Test("Report and Block are destructive; Unblock and the mutes are not") func destructive() { #expect(ProfileMenuItem.report.isDestructive) #expect(ProfileMenuItem.block.isDestructive) diff --git a/FlipcashTests/UserProfileChatActionsTests.swift b/FlipcashTests/UserProfileChatActionsTests.swift index 52f4c4a23..b0c52c384 100644 --- a/FlipcashTests/UserProfileChatActionsTests.swift +++ b/FlipcashTests/UserProfileChatActionsTests.swift @@ -34,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") From 65228b8f9438202c525f86868c36d326a58797d2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 09:22:54 -0400 Subject: [PATCH 03/13] fix(profile): match the Figma layout for both profiles The profile screens had the right controls in a centred, inset-card layout that didn't follow the design. Follow nodes 10961:314 and 10990:494 instead: a full-bleed 214pt banner (flat #323234 when there is no cover), a left-aligned 84pt avatar overlapping it, Edit Profile and Share in the avatar row, left-aligned name, handle and bio, and a two-column stats card with labels above values. Share opens a sheet with Share Profile, Show Profile Card and Copy Link in place of the menu. The other-user profile draws its back and overflow buttons over the banner with no navigation bar, and the pinned button is white over a fade. The Finish Your Profile card and the card-colour cover fallback are gone, with their tests. --- .../Screens/Main/You/ProfileTutorial.swift | 92 ----------- .../Core/Screens/Main/You/YouScreen.swift | 153 ++++++++---------- .../Screens/Profile/ProfileCoverBanner.swift | 46 +++--- .../Screens/Profile/ProfileCoverFill.swift | 20 --- .../Screens/Profile/ProfileHeaderView.swift | 138 +++++++++++----- .../Screens/Profile/ProfileShareSheet.swift | 106 ++++++++++++ .../Screens/Profile/ProfileStatsCard.swift | 29 ++-- .../Screens/Profile/UserProfileScreen.swift | 119 +++++++++++--- FlipcashTests/ProfileCoverFillTests.swift | 34 ---- FlipcashTests/ProfileTutorialStateTests.swift | 68 -------- .../Sources/FlipcashUI/Theme/Colors.swift | 2 + 11 files changed, 412 insertions(+), 395 deletions(-) delete mode 100644 Flipcash/Core/Screens/Main/You/ProfileTutorial.swift delete mode 100644 Flipcash/Core/Screens/Profile/ProfileCoverFill.swift create mode 100644 Flipcash/Core/Screens/Profile/ProfileShareSheet.swift delete mode 100644 FlipcashTests/ProfileCoverFillTests.swift delete mode 100644 FlipcashTests/ProfileTutorialStateTests.swift 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..88a9568d4 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 banner carries the "You" title and 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,47 +69,15 @@ 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) - } - .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { safeAreaTop = $0 } + // The banner runs under the status bar. + .ignoresSafeArea(edges: .top) } + .toolbar(.hidden, for: .navigationBar) .fullScreenCover(isPresented: Bindable(router).isShowingProfileCard) { ProfileCardScreen(previewCache: previewCache) } @@ -131,6 +95,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,23 +111,37 @@ 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 - } + bannerControls: { + HStack { + Text("You") + .font(.default(size: 23, weight: .semibold)) + .foregroundStyle(Color.textMain) + Spacer() settingsGear } + .padding(.leading, ProfileHeaderView.inset) + .padding(.trailing, 12) + .padding(.top, 65) + }, + rowActions: { + ProfileEditCapsule { + // The Edit Profile slice repoints this to `.editProfile`. + router.push(.settings) + } + .accessibilityIdentifier("you-edit-profile") + + if displayName != nil { + shareButton + } }, underHandle: { if shouldPromptForUsername { Button(action: claimUsername) { Text("Claim your username ›") - .font(.appTextSmall) + .font(.default(size: 14, weight: .medium)) .foregroundStyle(Color.textMain) - .padding(.vertical, 4) + .frame(minHeight: 22) } .buttonStyle(.plain) .accessibilityIdentifier("you-claim-username") @@ -169,13 +150,13 @@ struct YouScreen: View { ) } - private var shareMenu: some View { - Menu { - Button("Share Profile", action: shareTipCard) - Button("Show Profile Card") { router.isShowingProfileCard = true } + private var shareButton: some View { + Button { + isShowingShare = true } label: { - bannerControl(systemName: "square.and.arrow.up") + ProfileActionCircle(image: Image.asset(.shareOS)) } + .buttonStyle(.plain) .accessibilityLabel("Share") .accessibilityIdentifier("you-share") } @@ -184,23 +165,17 @@ struct YouScreen: View { Button { router.push(.settings) } label: { - bannerControl(systemName: "gearshape") + Image(systemName: "gearshape") + .font(.system(size: 22)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) } .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 +273,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 +296,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 +314,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 +340,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/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift index 9fe5ec8e1..37ed844b8 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,21 @@ 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() + shaded(image) } else if let preview = BlurHashCache.shared.image(for: coverPicture.thumbnailBlurhash) { - Image(uiImage: preview) - .resizable() - .scaledToFill() + shaded(preview) } else { - ProfileCoverFill.color(for: customization) + Color.coverPlaceholder } } else { - ProfileCoverFill.color(for: customization) + Color.coverPlaceholder } } + + private func shaded(_ image: UIImage) -> some View { + Image(uiImage: image) + .resizable() + .scaledToFill() + .overlay { Color.black.opacity(0.16) } + } } 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..92e886184 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,123 @@ 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: 14) { + rowActions() + } + .padding(.top, 20) + } + .padding(.horizontal, Self.inset) if let displayName { - VStack(spacing: 4) { - Text(displayName) - .font(.appDisplaySmall) - .foregroundStyle(Color.textMain) - .multilineTextAlignment(.center) - .accessibilityIdentifier("profile-name") + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text(displayName) + .font(.default(size: 28, weight: .bold)) + .tracking(-0.6) + .lineSpacing(1) + .frame(minHeight: 35) + .foregroundStyle(Color.textMain) + .accessibilityIdentifier("profile-name") + + if let statusChip { + statusChip + .fixedSize() + } + } if let handle { Text(handle) - .font(.appTextMedium) + .font(.default(size: 14, weight: .regular)) + .frame(minHeight: 22) .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(.default(size: 15, weight: .regular)) + .lineSpacing(4) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 14) + .accessibilityIdentifier("profile-bio") + } } + .padding(.top, 7) + .padding(.horizontal, Self.inset) } } } } + +/// A 38pt disc in the header's action row, filled at the row-surface tint. +struct ProfileActionCircle: View { + + let image: Image + + var body: some View { + image + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundStyle(Color.textMain) + .frame(width: 38, height: 38) + .background(Color.rowSeparator, in: Circle()) + .contentShape(Circle()) + } +} + +/// The header's "Edit Profile" capsule. +struct ProfileEditCapsule: View { + + let action: () -> Void + + var body: some View { + Button(action: action) { + Text("Edit Profile") + .font(.default(size: 12, weight: .semibold)) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 18) + .frame(height: 38) + .background(Color.rowSeparator, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift new file mode 100644 index 000000000..f99a409af --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift @@ -0,0 +1,106 @@ +// +// 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 { + VStack(spacing: 0) { + Capsule() + .fill(Color(r: 102, g: 102, b: 106)) + .frame(width: 40, height: 4) + .padding(.top, 8) + + VStack(spacing: 4) { + Text("Share Profile") + .font(.default(size: 21, weight: .semibold)) + .foregroundStyle(Color.textMain) + if let subtitle { + Text(subtitle) + .font(.default(size: 13, weight: .regular)) + .foregroundStyle(Color.textSecondary) + } + } + .padding(.top, 20) + .padding(.bottom, 12) + + row(icon: Image.asset(.shareOS), title: "Share Profile", identifier: "profile-share-row", choice: .share) + if offersCard { + separator + row(icon: Image(systemName: "person.crop.rectangle"), title: "Show Profile Card", identifier: "profile-card-row", choice: .showCard) + } + separator + row(icon: Image.asset(.chainLink), title: "Copy Link", identifier: "profile-copy-link-row", choice: .copyLink) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + .presentationDetents([.height(detentHeight)]) + .presentationDragIndicator(.hidden) + .presentationBackground(Color.backgroundSecondary) + .presentationCornerRadius(28) + } + + /// Handle, title block and rows, plus room for the home indicator. + private var detentHeight: CGFloat { + let rows: CGFloat = offersCard ? 3 : 2 + return 12 + 4 + 20 + 52 + 12 + rows * 64 + 34 + } + + private var separator: some View { + Color(r: 58, g: 58, b: 60) + .frame(height: 1) + .padding(.leading, 54) + } + + private func row(icon: Image, title: String, identifier: String, choice: ProfileShareChoice) -> some View { + Button { + onChoose(choice) + dismiss() + } label: { + HStack(spacing: 6) { + icon + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text(title) + .font(.default(size: 16, weight: .medium)) + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .frame(width: 20, height: 20) + .foregroundStyle(Color.textSecondary) + } + .foregroundStyle(Color.textMain) + .padding(.leading, 24) + .padding(.trailing, 24) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift index 39ee5766b..b5504d496 100644 --- a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift +++ b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift @@ -18,32 +18,39 @@ struct ProfileStatsCard: View { stat( title: "Minimum to Chat", value: minimumToChat?.formatted() ?? "—", + valueFont: .default(size: 23, weight: .semibold), 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: .default(size: 17, weight: .medium), 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: 16, 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: 0) { Text(title) - .font(.appTextSmall) + .font(.default(size: 11, weight: .medium)) + .frame(minHeight: 15) .foregroundStyle(Color.textSecondary) + Text(value) + .font(valueFont) + .foregroundStyle(Color.textMain) } - .frame(maxWidth: .infinity) + .padding(.leading, 16) + .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityIdentifier(identifier) } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index d2ac09caf..c866fad19 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit import FlipcashCore import FlipcashUI @@ -49,9 +50,12 @@ private struct UserProfileContent: View { @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? @State private var isPickingMuteDuration = false @@ -123,7 +127,7 @@ private struct UserProfileContent: View { var body: some View { Background(color: .backgroundMain) { ScrollView { - VStack(spacing: 16) { + VStack(spacing: 0) { ProfileHeaderView( userID: model.userID, displayName: model.displayName, @@ -132,23 +136,28 @@ private struct UserProfileContent: View { avatarData: model.imageData, avatarBlurhash: model.blurhash, coverPicture: model.coverPicture, - customization: model.customization, statusChip: statusChip, - bannerActions: { EmptyView() }, + bannerControls: { bannerControls }, + rowActions: { shareButton }, underHandle: { EmptyView() } ) ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt) + .padding(.top, 19) } - .padding(.horizontal, 20) + .padding(.bottom, 24) } + // The banner runs under the status bar. + .ignoresSafeArea(edges: .top) } - .safeAreaInset(edge: .bottom) { + .safeAreaInset(edge: .bottom, spacing: 0) { pinnedButton } - .navigationTitle("") - .toolbarTitleDisplayMode(.inline) - .toolbar { toolbarContent } + // The system bar is hidden; the interactive swipe-back stays with the navigation controller. + .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { + ProfileShareSheet(subtitle: shareSubtitle, offersCard: false) { shareChoice = $0 } + } .dialog(item: $dialogItem) .sheet(isPresented: $isPickingMuteDuration) { if let dmID { @@ -203,23 +212,26 @@ private struct UserProfileContent: View { return nil } - // MARK: - Toolbar - + // MARK: - Banner controls - - @ToolbarContentBuilder - private var toolbarContent: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { + private var bannerControls: some View { + HStack { Button { - model.share() + router.popTopmost() } label: { - Image.asset(.shareOS) - .renderingMode(.template) + Image(systemName: "chevron.left") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) } - .accessibilityLabel("Share profile") - .accessibilityIdentifier("profile-share") - } + .buttonStyle(.plain) + .accessibilityLabel("Back") + .accessibilityIdentifier("profile-back") + + Spacer() - if !menuItems.isEmpty { - ToolbarItem(placement: .topBarTrailing) { + if !menuItems.isEmpty { Menu { ForEach(menuItems, id: \.title) { item in Button(role: item.isDestructive ? .destructive : nil) { @@ -230,11 +242,46 @@ private struct UserProfileContent: View { } } label: { Image.system(.ellipsis) + .renderingMode(.template) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) } .accessibilityLabel("More") .accessibilityIdentifier("profile-overflow") } } + .padding(.leading, ProfileHeaderView.inset - 10) + .padding(.trailing, 12) + .padding(.top, 67) + } + + private var shareButton: some View { + Button { + isShowingShare = true + } label: { + ProfileActionCircle(image: Image.asset(.shareOS)) + } + .buttonStyle(.plain) + .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) { @@ -272,18 +319,37 @@ private struct UserProfileContent: View { private var pinnedButton: some View { if let title = pinnedAction.title { VStack(spacing: 8) { - Button(title) { - tapPinned() + Button(action: tapPinned) { + Text(title) + .font(.default(size: 15, weight: .semibold)) + .foregroundStyle(Color.textAction) + .frame(maxWidth: .infinity) + .frame(height: 60) + .background(Color.action, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .contentShape(Rectangle()) } - .buttonStyle(.filled) + .buttonStyle(.plain) .accessibilityIdentifier("profile-pinned-action") if showsE2eeFooter { E2eeFooter(kind: .dm) } } - .padding(.horizontal, 20) + .padding(.horizontal, ProfileHeaderView.inset) + .padding(.top, 40) .padding(.bottom, 8) + .background { + LinearGradient( + stops: [ + .init(color: Color.backgroundMain.opacity(0), location: 0), + .init(color: Color.backgroundMain.opacity(0.96), location: 0.3), + .init(color: Color.backgroundMain, location: 1) + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea(edges: .bottom) + } } else if showsE2eeFooter { E2eeFooter(kind: .dm) } @@ -471,9 +537,12 @@ final class UserProfileViewModel { 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 } } 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/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/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) } From 317452ffefcd44ee2b214a68eb0c9b80f44a251f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 09:33:32 -0400 Subject: [PATCH 04/13] fix(profile): system toolbar and soft edges, single Mute entry Move the You tab's gear and the other-user profile's back and overflow controls into the navigation bar, so the app-wide soft scroll edge draws the top fade. Drop the "You" title, the cover's dark overlay and the pinned button's gradient; the pinned button now sits in a scroll edge bar. Replace Mute/Unmute in the overflow menu with one "Mute Notifications" entry that opens MuteChatSheet, whose "Never" option unmutes. Top-align the stats card labels, and left-align the share sheet's "Share User Profile" title with a shorter detent. --- .../Core/Screens/Main/You/YouScreen.swift | 27 ++--- .../Screens/Profile/ProfileCoverBanner.swift | 7 +- .../Screens/Profile/ProfileMenuItems.swift | 13 +-- .../Screens/Profile/ProfileShareSheet.swift | 11 ++- .../Screens/Profile/ProfileStatsCard.swift | 4 +- .../Screens/Profile/UserProfileScreen.swift | 99 +++++-------------- FlipcashTests/ProfileMenuItemsTests.swift | 22 ++--- 7 files changed, 61 insertions(+), 122 deletions(-) diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 88a9568d4..7e94a3d52 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -11,7 +11,7 @@ import FlipcashUI /// The "You" tab: the user's own profile — cover, avatar, name, handle and bio — with the stats /// card. /// -/// The banner carries the "You" title and the gear that pushes Settings; the action row beside the +/// 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. @@ -77,7 +77,12 @@ struct YouScreen: View { // The banner runs under the status bar. .ignoresSafeArea(edges: .top) } - .toolbar(.hidden, for: .navigationBar) + // The system bar carries the gear and, on iOS 26, the soft edge the banner scrolls under. + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + settingsGear + } + } .fullScreenCover(isPresented: Bindable(router).isShowingProfileCard) { ProfileCardScreen(previewCache: previewCache) } @@ -112,18 +117,7 @@ struct YouScreen: View { avatarBlurhash: profilePicture?.thumbnailBlurhash, coverPicture: profile?.coverPicture, statusChip: nil, - bannerControls: { - HStack { - Text("You") - .font(.default(size: 23, weight: .semibold)) - .foregroundStyle(Color.textMain) - Spacer() - settingsGear - } - .padding(.leading, ProfileHeaderView.inset) - .padding(.trailing, 12) - .padding(.top, 65) - }, + bannerControls: { EmptyView() }, rowActions: { ProfileEditCapsule { // The Edit Profile slice repoints this to `.editProfile`. @@ -166,12 +160,7 @@ struct YouScreen: View { router.push(.settings) } label: { Image(systemName: "gearshape") - .font(.system(size: 22)) - .foregroundStyle(Color.textMain) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) } - .buttonStyle(.plain) .accessibilityLabel("Settings") .accessibilityIdentifier("you-settings") } diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift index 37ed844b8..452582a80 100644 --- a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -44,9 +44,9 @@ 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) { - shaded(image) + fill(image) } else if let preview = BlurHashCache.shared.image(for: coverPicture.thumbnailBlurhash) { - shaded(preview) + fill(preview) } else { Color.coverPlaceholder } @@ -55,10 +55,9 @@ struct ProfileCoverBanner: View { } } - private func shaded(_ image: UIImage) -> some View { + private func fill(_ image: UIImage) -> some View { Image(uiImage: image) .resizable() .scaledToFill() - .overlay { Color.black.opacity(0.16) } } } diff --git a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift index 490fb0430..d4c79ee3a 100644 --- a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -6,7 +6,6 @@ /// An entry in another user's overflow menu. nonisolated enum ProfileMenuItem: Equatable { case mute - case unmute case report case block case unblock @@ -14,8 +13,7 @@ nonisolated enum ProfileMenuItem: Equatable { /// The entry's label. var title: String { switch self { - case .mute: return "Mute" - case .unmute: return "Unmute" + case .mute: return "Mute Notifications" case .report: return "Report" case .block: return "Block" case .unblock: return "Unblock" @@ -26,7 +24,6 @@ nonisolated enum ProfileMenuItem: Equatable { var systemImage: String { switch self { case .mute: return "bell.slash" - case .unmute: return "bell" case .report: return "flag" case .block: return "nosign" case .unblock: return "checkmark.circle" @@ -37,7 +34,7 @@ nonisolated enum ProfileMenuItem: Equatable { var isDestructive: Bool { switch self { case .report, .block: return true - case .mute, .unmute, .unblock: return false + case .mute, .unblock: return false } } } @@ -45,11 +42,11 @@ nonisolated enum ProfileMenuItem: Equatable { /// The entries of another user's overflow menu. nonisolated enum ProfileMenuItems { - /// Mute and Unmute need a DM to silence and are hidden while blocked, since the DM is hidden too. - static func resolve(isBlocked: Bool, hasDM: Bool, isMuted: Bool) -> [ProfileMenuItem] { + /// 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(isMuted ? .unmute : .mute) } + if hasDM { items.append(.mute) } items.append(contentsOf: [.report, .block]) return items } diff --git a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift index f99a409af..5b78d5688 100644 --- a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift +++ b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift @@ -33,8 +33,8 @@ struct ProfileShareSheet: View { .frame(width: 40, height: 4) .padding(.top, 8) - VStack(spacing: 4) { - Text("Share Profile") + VStack(alignment: .leading, spacing: 4) { + Text("Share User Profile") .font(.default(size: 21, weight: .semibold)) .foregroundStyle(Color.textMain) if let subtitle { @@ -43,6 +43,8 @@ struct ProfileShareSheet: View { .foregroundStyle(Color.textSecondary) } } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) .padding(.top, 20) .padding(.bottom, 12) @@ -63,10 +65,11 @@ struct ProfileShareSheet: View { .presentationCornerRadius(28) } - /// Handle, title block and rows, plus room for the home indicator. + /// Handle, title block and rows, plus a small bottom margin. The sheet floats clear of the home + /// indicator on its own, so no extra room is added for it. private var detentHeight: CGFloat { let rows: CGFloat = offersCard ? 3 : 2 - return 12 + 4 + 20 + 52 + 12 + rows * 64 + 34 + return 12 + 4 + 20 + 52 + 12 + rows * 64 + 8 } private var separator: some View { diff --git a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift index b5504d496..eff382dfa 100644 --- a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift +++ b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift @@ -50,7 +50,9 @@ struct ProfileStatsCard: View { .foregroundStyle(Color.textMain) } .padding(.leading, 16) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 18) + // 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/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index c866fad19..b39976e95 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -102,14 +102,9 @@ private struct UserProfileContent: View { ProfilePinnedAction.resolve(isSelf: isSelf, isBlocked: isBlocked, dmID: dmID, fee: fee) } - private var isMuted: Bool { - guard let dmID else { return false } - return conversationController.conversation(withID: dmID)?.isMuted() ?? false - } - private var menuItems: [ProfileMenuItem] { guard !isSelf else { return [] } - return ProfileMenuItems.resolve(isBlocked: isBlocked, hasDM: dmID != nil, isMuted: isMuted) + return ProfileMenuItems.resolve(isBlocked: isBlocked, hasDM: dmID != nil) } /// Whether this profile is the visible top of its stack, with its tab or sheet active. @@ -137,7 +132,7 @@ private struct UserProfileContent: View { avatarBlurhash: model.blurhash, coverPicture: model.coverPicture, statusChip: statusChip, - bannerControls: { bannerControls }, + bannerControls: { EmptyView() }, rowActions: { shareButton }, underHandle: { EmptyView() } ) @@ -150,11 +145,18 @@ private struct UserProfileContent: View { // The banner runs under the status bar. .ignoresSafeArea(edges: .top) } - .safeAreaInset(edge: .bottom, spacing: 0) { + // On iOS 26 the pinned button joins the bottom scroll edge effect, so content fades under it. + .scrollEdgeBar(.bottom) { pinnedButton } - // The system bar is hidden; the interactive swipe-back stays with the navigation controller. - .toolbar(.hidden, for: .navigationBar) + // 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 + } + } + } .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { ProfileShareSheet(subtitle: shareSubtitle, offersCard: false) { shareChoice = $0 } } @@ -214,46 +216,21 @@ private struct UserProfileContent: View { // MARK: - Banner controls - - private var bannerControls: some View { - HStack { - Button { - router.popTopmost() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(Color.textMain) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Back") - .accessibilityIdentifier("profile-back") - - Spacer() - - if !menuItems.isEmpty { - Menu { - ForEach(menuItems, id: \.title) { item in - Button(role: item.isDestructive ? .destructive : nil) { - perform(item) - } label: { - Label(item.title, systemImage: item.systemImage) - } - } + private var overflowMenu: some View { + Menu { + ForEach(menuItems, id: \.title) { item in + Button(role: item.isDestructive ? .destructive : nil) { + perform(item) } label: { - Image.system(.ellipsis) - .renderingMode(.template) - .foregroundStyle(Color.textMain) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) + Label(item.title, systemImage: item.systemImage) } - .accessibilityLabel("More") - .accessibilityIdentifier("profile-overflow") } + } label: { + Image.system(.ellipsis) + .renderingMode(.template) } - .padding(.leading, ProfileHeaderView.inset - 10) - .padding(.trailing, 12) - .padding(.top, 67) + .accessibilityLabel("More") + .accessibilityIdentifier("profile-overflow") } private var shareButton: some View { @@ -288,22 +265,6 @@ private struct UserProfileContent: View { switch item { case .mute: isPickingMuteDuration = true - case .unmute: - guard let dmID else { return } - let chatType = conversationController.conversation(withID: dmID)?.type - Task { - do { - try await conversationController.unmute(conversationID: dmID) - Analytics.chatUnmuted(chatType: chatType, error: nil) - } catch { - Analytics.chatUnmuted(chatType: chatType, error: error) - session.dialogItem = .error( - title: "Something Went Wrong", - subtitle: "We were unable to unmute this chat. Please try again" - ) - ErrorReporting.captureError(error, reason: "Failed to unmute chat") - } - } case .report: isReporting = true case .block: @@ -336,20 +297,8 @@ private struct UserProfileContent: View { } } .padding(.horizontal, ProfileHeaderView.inset) - .padding(.top, 40) + .padding(.top, 12) .padding(.bottom, 8) - .background { - LinearGradient( - stops: [ - .init(color: Color.backgroundMain.opacity(0), location: 0), - .init(color: Color.backgroundMain.opacity(0.96), location: 0.3), - .init(color: Color.backgroundMain, location: 1) - ], - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea(edges: .bottom) - } } else if showsE2eeFooter { E2eeFooter(kind: .dm) } diff --git a/FlipcashTests/ProfileMenuItemsTests.swift b/FlipcashTests/ProfileMenuItemsTests.swift index e36ab099a..f4568773d 100644 --- a/FlipcashTests/ProfileMenuItemsTests.swift +++ b/FlipcashTests/ProfileMenuItemsTests.swift @@ -10,32 +10,32 @@ import Testing @Suite("ProfileMenuItems") struct ProfileMenuItemsTests { - @Test("A DM that is audible offers Mute, Report and Block") + @Test("A DM offers Mute, Report and Block") func dm_unmuted() { - #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: true, isMuted: false) == [.mute, .report, .block]) - } - - @Test("A muted DM offers Unmute in Mute's place") - func dm_muted() { - #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: true, isMuted: true) == [.unmute, .report, .block]) + #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, isMuted: false) == [.report, .block]) + #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, isMuted: false) == [.report, .unblock]) + #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 the mutes are not") + @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) - #expect(!ProfileMenuItem.unmute.isDestructive) } } From d4c6e51b1d5e697dd3484980047d728676862090 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 10:20:04 -0400 Subject: [PATCH 05/13] fix(profile): use the app's type, colour and sheet components The profile screens matched Figma pixel values: ten font sizes, four RGB colours and one-off radii and paddings that no other screen uses. Swap them for the app* font tokens, the textMain/textSecondary/backgroundRow colours, glass buttons, and the PartialSheet + ChatActionRow sheet that Mute Notifications already uses. --- .../Core/Screens/Main/You/YouScreen.swift | 7 +- .../Screens/Profile/ProfileHeaderView.swift | 49 ++++------ .../Screens/Profile/ProfileShareSheet.swift | 96 +++++++------------ .../Screens/Profile/ProfileStatsCard.swift | 11 +-- .../Screens/Profile/UserProfileScreen.swift | 20 +--- 5 files changed, 68 insertions(+), 115 deletions(-) diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 7e94a3d52..829a8d93d 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -133,7 +133,7 @@ struct YouScreen: View { if shouldPromptForUsername { Button(action: claimUsername) { Text("Claim your username ›") - .font(.default(size: 14, weight: .medium)) + .font(.appTextSmall) .foregroundStyle(Color.textMain) .frame(minHeight: 22) } @@ -145,12 +145,9 @@ struct YouScreen: View { } private var shareButton: some View { - Button { + ProfileActionCircle(image: Image.asset(.shareOS)) { isShowingShare = true - } label: { - ProfileActionCircle(image: Image.asset(.shareOS)) } - .buttonStyle(.plain) .accessibilityLabel("Share") .accessibilityIdentifier("you-share") } diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index 92e886184..dd08d326c 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -53,7 +53,7 @@ struct ProfileHeaderView Void var body: some View { - image - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 22, height: 22) - .foregroundStyle(Color.textMain) - .frame(width: 38, height: 38) - .background(Color.rowSeparator, in: Circle()) - .contentShape(Circle()) + Button(action: action) { + image + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.textMain) + } + .liquidGlassButtonStyle(shape: .circle) } } -/// The header's "Edit Profile" capsule. +/// The header's "Edit Profile" button, a glass capsule beside ``ProfileActionCircle``. struct ProfileEditCapsule: View { let action: () -> Void @@ -131,13 +128,9 @@ struct ProfileEditCapsule: View { var body: some View { Button(action: action) { Text("Edit Profile") - .font(.default(size: 12, weight: .semibold)) + .font(.appTextSmall) .foregroundStyle(Color.textMain) - .padding(.horizontal, 18) - .frame(height: 38) - .background(Color.rowSeparator, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .contentShape(Rectangle()) } - .buttonStyle(.plain) + .liquidGlassButtonStyle() } } diff --git a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift index 5b78d5688..f44bb7bbf 100644 --- a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift +++ b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift @@ -27,83 +27,59 @@ struct ProfileShareSheet: View { @Environment(\.dismiss) private var dismiss var body: some View { - VStack(spacing: 0) { - Capsule() - .fill(Color(r: 102, g: 102, b: 106)) - .frame(width: 40, height: 4) - .padding(.top, 8) + PartialSheet(background: .backgroundMain) { + VStack(spacing: 20) { + header - VStack(alignment: .leading, spacing: 4) { - Text("Share User Profile") - .font(.default(size: 21, weight: .semibold)) - .foregroundStyle(Color.textMain) if let subtitle { Text(subtitle) - .font(.default(size: 13, weight: .regular)) + .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) } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) - .padding(.top, 20) - .padding(.bottom, 12) - row(icon: Image.asset(.shareOS), title: "Share Profile", identifier: "profile-share-row", choice: .share) - if offersCard { - separator - row(icon: Image(systemName: "person.crop.rectangle"), title: "Show Profile Card", identifier: "profile-card-row", choice: .showCard) + 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) + } } - separator - row(icon: Image.asset(.chainLink), title: "Copy Link", identifier: "profile-copy-link-row", choice: .copyLink) - - Spacer(minLength: 0) + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, bottomPadding) } - .frame(maxWidth: .infinity) - .presentationDetents([.height(detentHeight)]) - .presentationDragIndicator(.hidden) - .presentationBackground(Color.backgroundSecondary) - .presentationCornerRadius(28) } - /// Handle, title block and rows, plus a small bottom margin. The sheet floats clear of the home - /// indicator on its own, so no extra room is added for it. - private var detentHeight: CGFloat { - let rows: CGFloat = offersCard ? 3 : 2 - return 12 + 4 + 20 + 52 + 12 + rows * 64 + 8 + /// 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() } + } + } } - private var separator: some View { - Color(r: 58, g: 58, b: 60) - .frame(height: 1) - .padding(.leading, 54) + /// 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 { - Button { + ChatActionRow(icon: icon, title: title, accessibilityIdentifier: identifier) { onChoose(choice) dismiss() - } label: { - HStack(spacing: 6) { - icon - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - Text(title) - .font(.default(size: 16, weight: .medium)) - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .frame(width: 20, height: 20) - .foregroundStyle(Color.textSecondary) - } - .foregroundStyle(Color.textMain) - .padding(.leading, 24) - .padding(.trailing, 24) - .frame(height: 64) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .accessibilityIdentifier(identifier) } } diff --git a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift index eff382dfa..109f14d57 100644 --- a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift +++ b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift @@ -18,7 +18,7 @@ struct ProfileStatsCard: View { stat( title: "Minimum to Chat", value: minimumToChat?.formatted() ?? "—", - valueFont: .default(size: 23, weight: .semibold), + valueFont: .appTextLarge, identifier: "profile-stat-minimum" ) .frame(width: 176) @@ -29,28 +29,27 @@ struct ProfileStatsCard: View { stat( title: "Date Joined", value: joinedAt?.formatted(.dateTime.month(.wide).year()) ?? "—", - valueFont: .default(size: 17, weight: .medium), + valueFont: .appTextLarge, identifier: "profile-stat-joined" ) .frame(maxWidth: .infinity) } .frame(height: 84) - .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) .padding(.horizontal, ProfileHeaderView.inset) } private func stat(title: String, value: String, valueFont: Font, identifier: String) -> some View { VStack(alignment: .leading, spacing: 0) { Text(title) - .font(.default(size: 11, weight: .medium)) - .frame(minHeight: 15) + .font(.appTextCaption) .foregroundStyle(Color.textSecondary) Text(value) .font(valueFont) .foregroundStyle(Color.textMain) } .padding(.leading, 16) - .padding(.top, 18) + .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) diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index b39976e95..0230dad57 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -138,7 +138,7 @@ private struct UserProfileContent: View { ) ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt) - .padding(.top, 19) + .padding(.top, 20) } .padding(.bottom, 24) } @@ -234,12 +234,9 @@ private struct UserProfileContent: View { } private var shareButton: some View { - Button { + ProfileActionCircle(image: Image.asset(.shareOS)) { isShowingShare = true - } label: { - ProfileActionCircle(image: Image.asset(.shareOS)) } - .buttonStyle(.plain) .accessibilityLabel("Share profile") .accessibilityIdentifier("profile-share") } @@ -280,17 +277,8 @@ private struct UserProfileContent: View { private var pinnedButton: some View { if let title = pinnedAction.title { VStack(spacing: 8) { - Button(action: tapPinned) { - Text(title) - .font(.default(size: 15, weight: .semibold)) - .foregroundStyle(Color.textAction) - .frame(maxWidth: .infinity) - .frame(height: 60) - .background(Color.action, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier("profile-pinned-action") + CodeButton(style: .filled, title: title, action: tapPinned) + .accessibilityIdentifier("profile-pinned-action") if showsE2eeFooter { E2eeFooter(kind: .dm) From 9e5e17a2d55645e24b5cef8391d846af8d5090c6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 10:40:48 -0400 Subject: [PATCH 06/13] fix(profile): hide the top scroll edge until the banner scrolls under the bar At rest the iOS 26 soft edge only darkened the cover banner and left a hard band where it stopped. --- .../Core/Screens/Main/You/YouScreen.swift | 2 ++ .../Screens/Profile/UserProfileScreen.swift | 2 ++ .../FlipcashUI/Modifiers/ScrollEdge.swift | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 829a8d93d..eee09c13c 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -76,6 +76,8 @@ struct YouScreen: View { } // 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 { diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 0230dad57..8ffc92b1b 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -144,6 +144,8 @@ private struct UserProfileContent: View { } // 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) } // On iOS 26 the pinned button joins the bottom scroll edge effect, so content fades under it. .scrollEdgeBar(.bottom) { 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 + } + } +} From d4542fce76236e9a034c9e810a1021850506c517 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 10:48:13 -0400 Subject: [PATCH 07/13] fix(profile): put the encryption notice above the pinned button --- Flipcash/Core/Screens/Profile/UserProfileScreen.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 8ffc92b1b..6a29005e3 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -279,12 +279,12 @@ private struct UserProfileContent: View { private var pinnedButton: some View { if let title = pinnedAction.title { VStack(spacing: 8) { - CodeButton(style: .filled, title: title, action: tapPinned) - .accessibilityIdentifier("profile-pinned-action") - if showsE2eeFooter { E2eeFooter(kind: .dm) } + + CodeButton(style: .filled, title: title, action: tapPinned) + .accessibilityIdentifier("profile-pinned-action") } .padding(.horizontal, ProfileHeaderView.inset) .padding(.top, 12) From e63b324c911168b9822ae05b681eac25ee6661a4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 10:53:45 -0400 Subject: [PATCH 08/13] fix(profile): move the Blocked and Muted badges into the action row Under the handle they took the bio's spot. The action row's height comes from the share button, so a badge appearing on mute or block no longer moves the content below it. The stats card uses Metrics.boxRadius like the app's other cards. --- .../Core/Screens/Main/You/YouScreen.swift | 1 - .../Screens/Profile/ChatMuteStatusLabel.swift | 11 +++++--- .../Screens/Profile/ProfileHeaderView.swift | 16 +++--------- .../Screens/Profile/ProfileStatsCard.swift | 2 +- .../Screens/Profile/UserProfileScreen.swift | 25 +++++++++++-------- 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index eee09c13c..06c400a5a 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -118,7 +118,6 @@ struct YouScreen: View { avatarData: sessionContainer.profileAvatars.data(for: sessionContainer.session.userID), avatarBlurhash: profilePicture?.thumbnailBlurhash, coverPicture: profile?.coverPicture, - statusChip: nil, bannerControls: { EmptyView() }, rowActions: { ProfileEditCapsule { diff --git a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index 2893a134c..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 diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index dd08d326c..700fd9d99 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -21,7 +21,6 @@ struct ProfileHeaderView BannerControls @ViewBuilder let rowActions: () -> RowActions @ViewBuilder let underHandle: () -> UnderHandle @@ -62,17 +61,10 @@ struct ProfileHeaderView.inset) } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 6a29005e3..97217a0c9 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -131,9 +131,11 @@ private struct UserProfileContent: View { avatarData: model.imageData, avatarBlurhash: model.blurhash, coverPicture: model.coverPicture, - statusChip: statusChip, bannerControls: { EmptyView() }, - rowActions: { shareButton }, + rowActions: { + statusBadges + shareButton + }, underHandle: { EmptyView() } ) @@ -201,19 +203,20 @@ private struct UserProfileContent: View { .task { await model.loadProfile() } } - // MARK: - Header chip - + // MARK: - Status badges - - private var statusChip: AnyView? { - if isBlocked { - return AnyView( + /// 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 { - return AnyView(ChatMuteStatusLabel(conversationID: dmID)) + } + if let dmID { + ChatMuteStatusLabel(conversationID: dmID, reservesSpace: false) + } } - return nil } // MARK: - Banner controls - From 7243babb00fe7066ffb508fc7fb7dbc5eb63bfd2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 10:59:42 -0400 Subject: [PATCH 09/13] fix(profile): flat 38pt header buttons instead of glass Share and Edit Profile now match the design (node 10961:323, 10961:335): 38pt tall, a 22pt share glyph, 18pt side padding on Edit Profile, and a flat fill at 10% of the action colour. Over the #19191A background that is the design's #303031. On glass the circle rendered at 34pt and the capsule at 31pt. --- .../Screens/Profile/ProfileHeaderView.swift | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index 700fd9d99..eec6a963d 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -92,8 +92,7 @@ struct ProfileHeaderView Void @@ -121,8 +120,25 @@ struct ProfileEditCapsule: View { Button(action: action) { Text("Edit Profile") .font(.appTextSmall) - .foregroundStyle(Color.textMain) + .padding(.horizontal, 18) } - .liquidGlassButtonStyle() + .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) } } From 7d2bf02e6d849b1140605ef134e29450cff19fa0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:27:30 -0400 Subject: [PATCH 10/13] fix(profile): set the bio in the app's message font appTextBody is Avenir Regular, a weight nothing else in the app uses, so the bio read as a different typeface next to the header. Use appTextMessage, the Medium weight chat bubbles use. --- Flipcash/Core/Screens/Profile/ProfileHeaderView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index eec6a963d..1373cc514 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -77,7 +77,7 @@ struct ProfileHeaderView Date: Tue, 6 Oct 2026 11:45:27 -0400 Subject: [PATCH 11/13] fix(profile): space the stats card label from its value to match the design --- Flipcash/Core/Screens/Profile/ProfileStatsCard.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift index 85f054bf1..85668f10b 100644 --- a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift +++ b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift @@ -40,7 +40,7 @@ struct ProfileStatsCard: View { } private func stat(title: String, value: String, valueFont: Font, identifier: String) -> some View { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { Text(title) .font(.appTextCaption) .foregroundStyle(Color.textSecondary) From 7947e6a9fbf2398900f9f1550ba8fc9be55b24b5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:16:25 -0400 Subject: [PATCH 12/13] fix(profile): raise toasts above the profile's pinned chat button The profile hides the tab bar, so the toast host drew 12pt above the bottom edge, over the pinned button. A screen can now mark a bottom bar with toastClearance(_:); the host measures it and keeps the toast above it while it is on screen. --- .../Screens/Profile/UserProfileScreen.swift | 2 ++ .../Views/Containers/ToastController.swift | 36 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 97217a0c9..e337c7390 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -152,6 +152,8 @@ private struct UserProfileContent: View { // 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 { 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) { From 16bc0a47b6cb0ebee60f9383de9139bef725e5bb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:19:44 -0400 Subject: [PATCH 13/13] fix(toast): rise a fixed 56pt when no tab bar shows The entrance moved the toast by its own padded height, so a toast raised over the profile's pinned button travelled up from the screen edge past the button. It now rises the 56pt a toast 12pt off the edge always did. --- .../Sources/FlipcashUI/Views/Containers/FloatingToast.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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(