diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index a745a7981..31f8982a9 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") - } + // 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/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" } 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