Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions Flipcash/Core/Screens/Profile/ChatProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Profile/ProfileMenuItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
49 changes: 49 additions & 0 deletions Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 7 additions & 1 deletion Flipcash/Core/Screens/Profile/UserProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions FlipcashTests/Chat/MessageCapabilityMenuTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
])
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1608,7 +1608,7 @@ private extension MessageCapability {
case .reply: .replyArrow
case .edit: .pencil
case .delete: .trash
case .report: .flag
case .report: .exclamationBubble
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 37 additions & 6 deletions FlipcashUI/Sources/FlipcashUI/Chat/MessageMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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]) {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading