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) + } +}