From cc721bebe67960b36d59b3fca8e7aabed598e8a1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 20:47:20 -0400 Subject: [PATCH 1/2] fix(profile): trim the group profile menu and set Report apart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group profile's ⋯ menu repeated what the screen already offers: Invite People does the same as Share → Share on Flipcash. The menu now holds Encryption (open lock, first), Mute Notifications for members, a divider, then Report as a red flag. Report is red and divided from the other entries in every menu that has it. The user profile menu gets a divider where the red entries start or stop. The message menu marks Report destructive, switches its icon to exclamationmark.bubble, and MessageMenuView draws a hairline before the first destructive row. The DM profile's encryption footer was unreadable over scrolled content, because the system scroll edge fade is too light for small text. Its pinned bar now draws the screen background with an eased 56pt fade above it, and the scroll content ends that much higher so the last row clears the fade. The group profile's Leave Chat sits 20pt lower: a text-only button is a full button tall, so the empty space under its title now overlaps the home indicator inset. --- .../Screens/Profile/ChatProfileScreen.swift | 30 ++++++------ .../Profile/ProfilePinnedBackdrop.swift | 49 +++++++++++++++++++ .../Screens/Profile/UserProfileScreen.swift | 8 ++- .../Conversation/MessageCapability.swift | 7 ++- .../Chat/MessageCapabilityMenuTests.swift | 6 +-- .../FlipcashUI/Chat/ChatViewController.swift | 2 +- .../FlipcashUI/Chat/MessageLiftOverlay.swift | 2 +- .../FlipcashUI/Chat/MessageMenuView.swift | 43 +++++++++++++--- .../FlipcashUI/Theme/Image+Symbols.swift | 2 +- 9 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index a745a7981..709351722 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -328,11 +328,14 @@ struct ChatProfileScreen: View { .buttonStyle(.subtle) .disabled(isLeaving) .accessibilityIdentifier("chat-profile-leave") + // A text-only button is a full button tall, so its frame already leaves room + // under the title; let that room overlap the home indicator's inset. + .padding(.bottom, -12) } } .padding(.horizontal, ProfileHeaderMetrics.inset) .padding(.top, 12) - .padding(.bottom, 8) + .padding(.bottom, isMember ? 0 : 8) } } @@ -459,32 +462,29 @@ struct ChatProfileScreen: View { private var overflowMenu: some View { Menu { - // Invite and Mute are a member's: someone reading a gated preview has no link to hand - // out and no viewer state to mute. + Button { + isShowingE2ee = true + } label: { + Label("Encryption", systemImage: "lock.open") + } + // Mute is a member's: someone reading a gated preview has no viewer state to mute. if isMember { - Button { - openInvite() - } label: { - Label("Invite People", systemImage: "person.badge.plus") - } Button { isPickingMuteDuration = true } label: { Label("Mute Notifications", systemImage: "bell.slash") } } + Divider() // Outside the membership check: a group you have already left is the one you are most // likely to report. - Button { + Button(role: .destructive) { isReporting = true } label: { - Label("Report", systemImage: "exclamationmark.bubble") - } - Button { - isShowingE2ee = true - } label: { - Label("Encryption", systemImage: "lock") + Label("Report", systemImage: "flag") } + // Menu icons follow the app's white tint while the title follows the role. + .tint(.red) } label: { Image.system(.ellipsis) .renderingMode(.template) diff --git a/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift b/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift new file mode 100644 index 000000000..863027fd7 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift @@ -0,0 +1,49 @@ +// +// ProfilePinnedBackdrop.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +extension View { + + /// Backs a profile's pinned bottom bar with the screen's background and a fade above it, so the + /// bar's text stays readable over the content scrolling underneath. + /// + /// The system scroll edge effect fades too gradually for the encryption line to read over an + /// avatar grid. + func profilePinnedBackdrop() -> some View { + background { + VStack(spacing: 0) { + // Eased rather than linear: it turns mostly opaque early, so a row cut off at the + // top reads as a soft shadow instead of a hard sliver. + LinearGradient( + stops: [ + .init(color: Color.backgroundMain.opacity(0), location: 0), + .init(color: Color.backgroundMain.opacity(0.55), location: 0.3), + .init(color: Color.backgroundMain.opacity(0.85), location: 0.6), + .init(color: Color.backgroundMain, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: ProfilePinnedBackdrop.fadeHeight) + Color.backgroundMain + } + .padding(.top, -ProfilePinnedBackdrop.fadeHeight) + .ignoresSafeArea(edges: .bottom) + .allowsHitTesting(false) + } + } + + /// Ends a profile's scroll content above the fade ``profilePinnedBackdrop()`` draws over the + /// pinned bar, so the last row is readable when scrolled to the bottom. Apply to the scroll view. + func profilePinnedBackdropClearance() -> some View { + contentMargins(.bottom, ProfilePinnedBackdrop.fadeHeight, for: .scrollContent) + } +} + +private enum ProfilePinnedBackdrop { + static let fadeHeight: CGFloat = 56 +} diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index bc495528f..be7c46770 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -154,6 +154,7 @@ private struct UserProfileContent: View { } .padding(.bottom, 24) } + .profilePinnedBackdropClearance() // The banner runs under the status bar. .ignoresSafeArea(edges: .top) // The blur only belongs once the banner has scrolled up under the bar. @@ -162,6 +163,7 @@ private struct UserProfileContent: View { // On iOS 26 the pinned button joins the bottom scroll edge effect, so content fades under it. .scrollEdgeBar(.bottom) { pinnedButton + .profilePinnedBackdrop() // Toasts rise above the button rather than covering it. .toastClearance(toasts) } @@ -238,7 +240,11 @@ private struct UserProfileContent: View { private var overflowMenu: some View { Menu { - ForEach(menuItems, id: \.title) { item in + ForEach(Array(menuItems.enumerated()), id: \.element.title) { index, item in + // A divider wherever the red entries start or stop, so they sit apart. + if index > 0, menuItems[index - 1].isDestructive != item.isDestructive { + Divider() + } Button(role: item.isDestructive ? .destructive : nil) { perform(item) } label: { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index bfb97ea25..d82a2c98d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -33,10 +33,9 @@ extension MessageCapability { /// Whether the menu should render this row in its destructive style. public var isDestructive: Bool { switch self { - // Report is not destructive, though a reviewer will expect it to be: nothing is destroyed - // and nothing is irreversible from the reporter's side. The red row belongs to delete. - case .copy, .reply, .edit, .report: false - case .delete: true + // Report is red to match Report on the profile menus, though nothing is destroyed. + case .copy, .reply, .edit: false + case .delete, .report: true } } } diff --git a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift index 49c86827d..00b3fecab 100644 --- a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift +++ b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift @@ -97,8 +97,8 @@ struct MessageCapabilityMenuTests { #expect(notified.map(\.1) == [.edit, .delete]) } - @Test("Report renders last, and is not marked destructive") - func reportIsOfferedButNotDestructive() { + @Test("Report renders last, and is marked destructive") + func reportIsOfferedAndDestructive() { let controller = loadedController([ .message(ChatMessage(id: "1", text: "hi", sender: .other, actions: [.copy, .reply, .report])) ]) @@ -110,7 +110,7 @@ struct MessageCapabilityMenuTests { Issue.record("expected a Report action") return } - #expect(!report.attributes.contains(.destructive)) + #expect(report.attributes.contains(.destructive)) } @Test("Report reports the row's id to the screen") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 4215a7a5f..789fa8f8f 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -1608,7 +1608,7 @@ private extension MessageCapability { case .reply: .replyArrow case .edit: .pencil case .delete: .trash - case .report: .flag + case .report: .exclamationBubble } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageLiftOverlay.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLiftOverlay.swift index a91025d83..1a37d78b6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageLiftOverlay.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageLiftOverlay.swift @@ -73,7 +73,7 @@ final class MessageLiftOverlay: NSObject, UIGestureRecognizerDelegate { safeArea: (window.safeAreaInsets.top, window.safeAreaInsets.bottom), stripHeight: entries == nil ? nil : ReactionStripView.height, stripGap: ReactionStripView.bubbleGap, - menuSize: menuActions.map { MessageMenuView.size(rows: $0.count) } + menuSize: menuActions.map { MessageMenuView.size(for: $0) } ) let scale = layout.bubble.height / home.height diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift index 3d2aacf7c..b86eb9f35 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift @@ -10,7 +10,8 @@ import UIKit /// The action list under a long-pressed bubble, drawn like a system context menu on a Liquid Glass /// platter. Rows come from the message's `UIAction`s, so the menu and its tests share one mapping. -/// A row can be tapped, or reached by dragging the pressing finger onto it and lifting. +/// A row can be tapped, or reached by dragging the pressing finger onto it and lifting. A divider +/// sets the destructive rows apart from the ones above them. final class MessageMenuView: UIView { /// Fired with the chosen action, before it is performed. @@ -22,15 +23,29 @@ final class MessageMenuView: UIView { private static let horizontalInset: CGFloat = 16 private static let iconWidth: CGFloat = 24 private static let cornerRadius: CGFloat = 24 + private static let dividerHeight: CGFloat = 13 private let actions: [UIAction] private let surface: UIView private var rows: [UIControl] = [] private let selection = UISelectionFeedbackGenerator() - /// The menu's size for `count` rows. - static func size(rows count: Int) -> CGSize { - CGSize(width: width, height: CGFloat(count) * rowHeight + verticalInset * 2) + /// The menu's size for `actions`. + static func size(for actions: [UIAction]) -> CGSize { + let dividers: CGFloat = dividerIndex(in: actions) == nil ? 0 : 1 + return CGSize( + width: width, + height: CGFloat(actions.count) * rowHeight + dividers * dividerHeight + verticalInset * 2 + ) + } + + /// The index of the first destructive action when a non-destructive one comes before it, which + /// is where the divider goes. + private static func dividerIndex(in actions: [UIAction]) -> Int? { + guard let index = actions.firstIndex(where: { $0.attributes.contains(.destructive) }), index > 0 else { + return nil + } + return index } init(actions: [UIAction]) { @@ -45,16 +60,32 @@ final class MessageMenuView: UIView { surface.layer.cornerCurve = .continuous surface.clipsToBounds = true } - super.init(frame: CGRect(origin: .zero, size: Self.size(rows: actions.count))) + super.init(frame: CGRect(origin: .zero, size: Self.size(for: actions))) surface.frame = bounds surface.autoresizingMask = [.flexibleWidth, .flexibleHeight] addSubview(surface) let content = (surface as? UIVisualEffectView)?.contentView ?? surface + let dividerIndex = Self.dividerIndex(in: actions) + var y = Self.verticalInset for (index, action) in actions.enumerated() { + if index == dividerIndex { + let hairline: CGFloat = 0.5 + let divider = UIView(frame: CGRect( + x: Self.horizontalInset, + y: y + (Self.dividerHeight - hairline) / 2, + width: bounds.width - Self.horizontalInset * 2, + height: hairline + )) + divider.backgroundColor = .separator + divider.autoresizingMask = [.flexibleWidth] + content.addSubview(divider) + y += Self.dividerHeight + } let row = Self.row(for: action) - row.frame = CGRect(x: 0, y: Self.verticalInset + CGFloat(index) * Self.rowHeight, width: bounds.width, height: Self.rowHeight) + row.frame = CGRect(x: 0, y: y, width: bounds.width, height: Self.rowHeight) + y += Self.rowHeight row.autoresizingMask = [.flexibleWidth] row.tag = index row.addTarget(self, action: #selector(rowTapped), for: .touchUpInside) diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index 260da5f99..d8131d3ea 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -84,7 +84,7 @@ public enum SystemSymbol: String { case pencil = "pencil" case trash = "trash" - case flag = "flag" + case exclamationBubble = "exclamationmark.bubble" /// The two rows of the group edit list, matched to the glyphs Android uses for the same rows /// (`Icons.Outlined.Image` and `Icons.Outlined.TextFields`) so the screen reads the same on From e753ab2c2d3b875f7b05f2a46414f0c05ed8fbd6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 21:01:22 -0400 Subject: [PATCH 2/2] fix(profile): use the speech-bubble Report icon on both profile menus The message menu already uses exclamationmark.bubble, so Report now uses the same icon everywhere it appears. --- Flipcash/Core/Screens/Profile/ChatProfileScreen.swift | 2 +- Flipcash/Core/Screens/Profile/ProfileMenuItems.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 709351722..31f8982a9 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -481,7 +481,7 @@ struct ChatProfileScreen: View { Button(role: .destructive) { isReporting = true } label: { - Label("Report", systemImage: "flag") + Label("Report", systemImage: "exclamationmark.bubble") } // Menu icons follow the app's white tint while the title follows the role. .tint(.red) diff --git a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift index d4c79ee3a..32d5ce11d 100644 --- a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -24,7 +24,7 @@ nonisolated enum ProfileMenuItem: Equatable { var systemImage: String { switch self { case .mute: return "bell.slash" - case .report: return "flag" + case .report: return "exclamationmark.bubble" case .block: return "nosign" case .unblock: return "checkmark.circle" }