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
50 changes: 36 additions & 14 deletions Flipcash/Core/Screens/Profile/ChatProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)?",
Expand Down
85 changes: 62 additions & 23 deletions Flipcash/Core/Screens/Profile/ProfilePinnedBackdrop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProfileScrollFit>) -> 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 {
Expand Down
32 changes: 29 additions & 3 deletions Flipcash/Core/Screens/Profile/UserProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
48 changes: 48 additions & 0 deletions FlipcashTests/ProfileScrollFitTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading