From 75e538fa81048d19b26bc8bd98f223bddba75c81 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 23:05:08 -0400 Subject: [PATCH 1/2] fix(profile): end a barless profile's scroll at Leave Chat Opened from the chat, a group profile long enough to scroll left about 80pt of empty space under Leave Chat: the pinned bar's 56pt clearance stayed on with no bar to clear, under the content's own 24pt padding. A profile without a pinned bar now takes no clearance. Leave Chat ending the scroll sits where the pinned bar puts it, overlapping the home indicator's inset by 12pt, and the barless fade covers only that inset so it no longer dims the button. The DM profile's encryption line gets the same clearance and fade. ProfileScrollFit no longer offsets for a clearance that turned on with overflow, since none does. --- .../Screens/Profile/ChatProfileScreen.swift | 20 +++++++++++++++---- .../Profile/ProfilePinnedBackdrop.swift | 15 ++++++-------- .../Screens/Profile/UserProfileScreen.swift | 2 +- FlipcashTests/ProfileScrollFitTests.swift | 16 +++------------ 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 388706726..6cab5fe0f 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -148,17 +148,17 @@ struct ChatProfileScreen: View { .padding(.top, 24) } - if origin == .chat, isMember { + if endsWithLeaveButton { // 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) + .frame(minHeight: max(scrollFit.visibleHeight - contentBottomPadding, 0), alignment: .top) + .padding(.bottom, contentBottomPadding) } - .profilePinnedBackdropClearance(isActive: origin != .chat || scrollFit.overflows) + .profilePinnedBackdropClearance(isActive: origin != .chat) .profileScrollFit($scrollFit) // The banner runs under the status bar. .ignoresSafeArea(edges: .top) @@ -333,6 +333,18 @@ struct ChatProfileScreen: View { return holding(stated, mint: mint) } + /// Whether Leave Chat ends the scroll content rather than sitting in the pinned bar. + private var endsWithLeaveButton: Bool { + origin == .chat && isMember + } + + /// Space under the scroll content. Leave Chat ending it sits where the pinned bar puts it: a + /// text-only button is a full button tall, so its frame already leaves room under the title and + /// can overlap the home indicator's inset. + private var contentBottomPadding: CGFloat { + endsWithLeaveButton ? -12 : 24 + } + // MARK: - Pinned - @ViewBuilder diff --git a/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift b/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift index 4f2531061..f1c808aa6 100644 --- a/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift +++ b/Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift @@ -40,8 +40,9 @@ extension View { } /// Fades content into the bottom of a profile that pins no bar, the way the chat's transcript - /// dissolves under its composer: a ramp from the clearance's top edge to the screen's bottom edge. - /// `isActive` false draws nothing, for a screen whose content doesn't scroll. + /// dissolves under its composer: a ramp across the home indicator's inset only, so the row that + /// ends the scroll stays clear of it. `isActive` false draws nothing, for a screen whose content + /// doesn't scroll. func profileBarlessFade(isActive: Bool) -> some View { background { if isActive { @@ -50,7 +51,6 @@ extension View { startPoint: .top, endPoint: .bottom ) - .padding(.top, -ProfilePinnedBackdrop.fadeHeight) .ignoresSafeArea(edges: .bottom) .allowsHitTesting(false) } @@ -82,17 +82,14 @@ 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. + /// Whether the content runs past the screen, which is when a barless profile's bottom fade + /// belongs. 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 + overflows = contentHeight > visibleHeight + 0.5 } } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 2f9e16809..760029a87 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -178,7 +178,7 @@ private struct UserProfileContent: View { .frame(minHeight: max(scrollFit.visibleHeight - 24, 0), alignment: .top) .padding(.bottom, 24) } - .profilePinnedBackdropClearance(isActive: !hidesPinnedBar || scrollFit.overflows) + .profilePinnedBackdropClearance(isActive: !hidesPinnedBar) .profileScrollFit($scrollFit) // The banner runs under the status bar. .ignoresSafeArea(edges: .top) diff --git a/FlipcashTests/ProfileScrollFitTests.swift b/FlipcashTests/ProfileScrollFitTests.swift index 3e05274b9..02f4f2053 100644 --- a/FlipcashTests/ProfileScrollFitTests.swift +++ b/FlipcashTests/ProfileScrollFitTests.swift @@ -26,23 +26,13 @@ struct ProfileScrollFitTests { #expect(fit.overflows) } - @Test("Content that shrinks to fit releases the clearance it turned on") - func clearance_releases() { + @Test("Content that shrinks to fit stops overflowing") + func shrinks_to_fit() { 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) + fit.update(visibleHeight: 700, contentHeight: 700) #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) - } } From 08b85c9b30c7483e6809bd8584b57384e24396e1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 23:05:08 -0400 Subject: [PATCH 2/2] feat(profile): hide the scroll indicator on profile screens --- Flipcash/Core/Screens/Profile/ChatProfileScreen.swift | 1 + Flipcash/Core/Screens/Profile/UserProfileScreen.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 6cab5fe0f..2d768c561 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -160,6 +160,7 @@ struct ChatProfileScreen: View { } .profilePinnedBackdropClearance(isActive: origin != .chat) .profileScrollFit($scrollFit) + .scrollIndicators(.hidden) // The banner runs under the status bar. .ignoresSafeArea(edges: .top) // The blur only belongs once the banner has scrolled up under the bar. diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 760029a87..260cdc4c6 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -180,6 +180,7 @@ private struct UserProfileContent: View { } .profilePinnedBackdropClearance(isActive: !hidesPinnedBar) .profileScrollFit($scrollFit) + .scrollIndicators(.hidden) // The banner runs under the status bar. .ignoresSafeArea(edges: .top) // The blur only belongs once the banner has scrolled up under the bar.