From ec819e2a596a0bd19927222ef7f00d85491074d0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 18:52:03 -0400 Subject: [PATCH] feat(profile): drop the pinned footer when opened from the chat A profile opened from its own chat pinned Open Chat, which only returns to the screen underneath. From the chat, the group profile now hides Open Chat and puts Leave Chat in the scroll, and the user profile drops its bar when Open Chat was the pinned button, moving the e2ee footer into the scroll. Unblock and Start Chatting stay pinned. When the content fits, Leave Chat and the e2ee footer sit at the bottom of the screen with no fade. When it scrolls, the bottom fade and its clearance return. ProfileScrollFit decides which, and keeps the clearance from flipping it back once it's on. This matches code-android-app#1733, except Android blurs the bottom edge with Haze where iOS keeps the existing fade. --- .../Screens/Profile/ChatProfileScreen.swift | 50 ++++++++--- .../Profile/ProfilePinnedBackdrop.swift | 85 ++++++++++++++----- .../Screens/Profile/UserProfileScreen.swift | 32 ++++++- FlipcashTests/ProfileScrollFitTests.swift | 48 +++++++++++ 4 files changed, 175 insertions(+), 40 deletions(-) create mode 100644 FlipcashTests/ProfileScrollFitTests.swift diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index bac94a332..50d1e9c4a 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -16,8 +16,9 @@ import FlipcashStore /// button that opens the chat. /// /// Reached by tapping the chat's head card or its navigation title, the way a DM's title opens the -/// counterpart's profile. The actions a member has over the group sit in the ⋯ menu, and Leave Chat -/// sits under the pinned button. +/// counterpart's profile. The actions a member has over the group sit in the ⋯ menu. Opened from +/// the chat, there is no pinned button, since the chat is one back away, and Leave Chat ends the +/// scroll; otherwise Leave Chat sits under the pinned button. struct ChatProfileScreen: View { let conversationID: ConversationID @@ -38,6 +39,7 @@ struct ChatProfileScreen: View { @State private var chatters: [SampledChatter] = [] @State private var mintMetadata: [PublicKey: StoredMintMetadata] = [:] @State private var mintNamesTimedOut = false + @State private var scrollFit = ProfileScrollFit() private var session: Session { sessionContainer.session } @@ -145,9 +147,19 @@ struct ChatProfileScreen: View { balanceRequirements(requirements) .padding(.top, 24) } + + if origin == .chat, isMember { + // Holds Leave Chat at the bottom of the screen when the content is short. + Spacer(minLength: 24) + leaveButton + .padding(.horizontal, ProfileHeaderMetrics.inset) + } } + .frame(minHeight: max(scrollFit.visibleHeight - 24, 0), alignment: .top) .padding(.bottom, 24) } + .profilePinnedBackdropClearance(isActive: origin != .chat || scrollFit.overflows) + .profileScrollFit($scrollFit) // The banner runs under the status bar. .ignoresSafeArea(edges: .top) // The blur only belongs once the banner has scrolled up under the bar. @@ -325,21 +337,20 @@ struct ChatProfileScreen: View { @ViewBuilder private var pinnedActions: some View { - if conversation != nil { + // Opened from the chat, the chat is one back away and Leave Chat ends the scroll instead; + // the empty bar fades out content that scrolls under the home indicator. + if origin == .chat { + Color.clear + .frame(height: 0) + .profilePinnedBackdrop(isActive: scrollFit.overflows) + } else if conversation != nil { VStack(spacing: 8) { openChatButton if isMember { - Button { - dialogItem = leaveDialog() - } label: { - ButtonStateLabel("Leave Chat", state: isLeaving ? .loading : .normal) - } - .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) + leaveButton + // 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) @@ -420,6 +431,17 @@ struct ChatProfileScreen: View { // MARK: - Leave - + private var leaveButton: some View { + Button { + dialogItem = leaveDialog() + } label: { + ButtonStateLabel("Leave Chat", state: isLeaving ? .loading : .normal) + } + .buttonStyle(.subtle) + .disabled(isLeaving) + .accessibilityIdentifier("chat-profile-leave") + } + private func leaveDialog() -> DialogItem { .info( title: "Leave \(title)?", diff --git a/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift b/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift index 863027fd7..d6a6b9c94 100644 --- a/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift +++ b/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift @@ -12,36 +12,75 @@ extension View { /// 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 { + /// avatar grid. `isActive` false draws nothing, for a screen whose content doesn't scroll. + func profilePinnedBackdrop(isActive: Bool = true) -> 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 + if isActive { + 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) } - .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) + /// pinned bar, so the last row is readable when scrolled to the bottom. Apply to the scroll view; + /// `isActive` false leaves the margin off a screen that draws no backdrop. + func profilePinnedBackdropClearance(isActive: Bool = true) -> some View { + contentMargins(.bottom, isActive ? ProfilePinnedBackdrop.fadeHeight : 0, for: .scrollContent) } + + /// Keeps `fit` current with how the scroll view's content fits it, so a profile can stretch + /// short content to the screen and show the fade only when content scrolls. Apply to the scroll + /// view. + func profileScrollFit(_ fit: Binding) -> some View { + onScrollGeometryChange(for: ScrollHeights.self) { geometry in + ScrollHeights(visible: geometry.containerSize.height, content: geometry.contentSize.height) + } action: { _, heights in + fit.wrappedValue.update(visibleHeight: heights.visible, contentHeight: heights.content) + } + } +} + +/// How a profile's scroll content fits the screen. +struct ProfileScrollFit: Equatable { + + /// The height the scroll view shows content in, which short content stretches to. + private(set) var visibleHeight: CGFloat = 0 + + /// Whether the content runs past the screen, which is when the bottom fade and its clearance + /// belong. + private(set) var overflows = false + + /// Records the scroll view's visible height and its content's height. + mutating func update(visibleHeight: CGFloat, contentHeight: CGFloat) { + self.visibleHeight = visibleHeight + // The clearance shrinks the visible height while it is on; compare against the height + // without it, or turning it on would keep it on. + let clearance = overflows ? ProfilePinnedBackdrop.fadeHeight : 0 + overflows = contentHeight > visibleHeight + clearance + 0.5 + } +} + +private struct ScrollHeights: Equatable { + let visible: CGFloat + let content: CGFloat } private enum ProfilePinnedBackdrop { diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index b6235b10e..27407d9dd 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -55,6 +55,7 @@ private struct UserProfileContent: View { let origin: UserProfileOrigin @State private var isShowingShare = false + @State private var scrollFit = ProfileScrollFit() @State private var model: UserProfileViewModel @State private var dialogItem: DialogItem? @State private var isPickingMuteDuration = false @@ -101,6 +102,18 @@ private struct UserProfileContent: View { ProfilePinnedAction.resolve(isSelf: isSelf, isBlocked: isBlocked, dmID: dmID, fee: fee) } + /// Whether the bottom bar gives way because Open Chat would only go back to the DM underneath. + /// The encryption line then ends the scroll instead. + private var hidesPinnedBar: Bool { + guard origin.returnsToExistingDM else { return false } + switch pinnedAction { + case .openChat: + return true + case .none, .unblock, .startChatting, .startChattingUnpriced: + return false + } + } + private var menuItems: [ProfileMenuItem] { guard !isSelf else { return [] } return ProfileMenuItems.resolve(isBlocked: isBlocked, hasDM: dmID != nil) @@ -153,10 +166,20 @@ private struct UserProfileContent: View { router.push(.chatProfile(id, origin: .featuredGroup)) } .padding(.top, 20) + + if hidesPinnedBar, showsE2eeFooter { + // Holds the encryption line at the bottom of the screen when the content + // is short. + Spacer(minLength: 24) + E2eeFooter(kind: .dm) + .padding(.horizontal, ProfileHeaderMetrics.inset) + } } + .frame(minHeight: max(scrollFit.visibleHeight - 24, 0), alignment: .top) .padding(.bottom, 24) } - .profilePinnedBackdropClearance() + .profilePinnedBackdropClearance(isActive: !hidesPinnedBar || scrollFit.overflows) + .profileScrollFit($scrollFit) // The banner runs under the status bar. .ignoresSafeArea(edges: .top) // The blur only belongs once the banner has scrolled up under the bar. @@ -165,7 +188,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() + .profilePinnedBackdrop(isActive: !hidesPinnedBar || scrollFit.overflows) // Toasts rise above the button rather than covering it. .toastClearance(toasts) } @@ -298,7 +321,10 @@ private struct UserProfileContent: View { @ViewBuilder private var pinnedButton: some View { - if let title = pinnedAction.title { + if hidesPinnedBar { + // Nothing to pin, but content that scrolls still fades out under the home indicator. + Color.clear.frame(height: 0) + } else if let title = pinnedAction.title { VStack(spacing: 8) { if showsE2eeFooter { E2eeFooter(kind: .dm) diff --git a/FlipcashTests/ProfileScrollFitTests.swift b/FlipcashTests/ProfileScrollFitTests.swift new file mode 100644 index 000000000..3e05274b9 --- /dev/null +++ b/FlipcashTests/ProfileScrollFitTests.swift @@ -0,0 +1,48 @@ +// +// ProfileScrollFitTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import Flipcash + +@MainActor +@Suite("ProfileScrollFit") +struct ProfileScrollFitTests { + + @Test("Content shorter than the screen doesn't overflow") + func short_fits() { + var fit = ProfileScrollFit() + fit.update(visibleHeight: 700, contentHeight: 700) + #expect(fit.overflows == false) + #expect(fit.visibleHeight == 700) + } + + @Test("Content taller than the screen overflows") + func tall_overflows() { + var fit = ProfileScrollFit() + fit.update(visibleHeight: 700, contentHeight: 1200) + #expect(fit.overflows) + } + + @Test("Content that shrinks to fit releases the clearance it turned on") + func clearance_releases() { + var fit = ProfileScrollFit() + fit.update(visibleHeight: 700, contentHeight: 1200) + #expect(fit.overflows) + + // The 56pt clearance leaves 644 visible; 690 overflows that but fits the 700 the screen + // has without it. + fit.update(visibleHeight: 644, contentHeight: 690) + #expect(fit.overflows == false) + } + + @Test("Overflowing content stays overflowing once the clearance is on") + func clearance_holds() { + var fit = ProfileScrollFit() + fit.update(visibleHeight: 700, contentHeight: 1200) + fit.update(visibleHeight: 644, contentHeight: 1200) + #expect(fit.overflows) + } +}