diff --git a/.maestro/you-tab-download.yaml b/.maestro/you-tab-download.yaml index b914d542c..fee120cc7 100644 --- a/.maestro/you-tab-download.yaml +++ b/.maestro/you-tab-download.yaml @@ -1,10 +1,12 @@ -# Drives the You tab's export path end to end, once per format: card page -> -# Download -> pick a format -> the system share sheet holding the written file. +# Drives the You tab's export path end to end, once per format: Share menu -> +# Show Profile Card -> Download -> pick a format -> the system share sheet holding the written file. appId: com.flipcash.app.ios --- - launchApp - tapOn: "You" -- assertVisible: "Full Screen" +- tapOn: + id: "you-share" +- tapOn: "Show Profile Card" # --- SVG --- - tapOn: @@ -23,6 +25,9 @@ appId: com.flipcash.app.ios # Relaunching is the cheapest way to get the system sheet off screen. - launchApp - tapOn: "You" +- tapOn: + id: "you-share" +- tapOn: "Show Profile Card" - tapOn: id: "you-download-button" - tapOn: diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index d28088ec5..9d58e58f5 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -254,8 +254,8 @@ struct DeepLinkAction { } /// Whether a person link names the viewer, answerable without a lookup only when the id or the - /// handle is already known locally. A link to the viewer keeps opening their own tip card, as it - /// did before person links opened a profile. + /// handle is already known locally. A link to the viewer opens their own profile, the same as + /// scanning their own code. static func isOwnLink( _ identifier: ProfileIdentifier, selfUserID: UserID, @@ -425,7 +425,7 @@ struct DeepLinkAction { if let container = sessionAuthenticator.loggedInContainer { Analytics.deeplinkRouted(kind: kind) // `begin` owns the own-id case: a self link lands on the user's - // own tip card rather than starting a tip. + // own profile rather than starting a tip. container.tipFlow.begin(userID: userID) } diff --git a/Flipcash/Core/Controllers/ProfileAvatarStore.swift b/Flipcash/Core/Controllers/ProfileAvatarStore.swift index 68f9d2a15..9b76165aa 100644 --- a/Flipcash/Core/Controllers/ProfileAvatarStore.swift +++ b/Flipcash/Core/Controllers/ProfileAvatarStore.swift @@ -23,12 +23,23 @@ final class ProfileAvatarStore { enum AvatarSubject: Hashable, Sendable { case user(UserID) case chat(ConversationID) + /// A user's cover banner. Authorizes like the user's avatar but holds the full-size blob. + case cover(UserID) /// The context this subject's blobs authorize through. var accessContext: BlobAccessContext { switch self { case .user(let userID): .userProfile(userID) case .chat(let conversationID): .chatProfile(conversationID) + case .cover(let userID): .userProfile(userID) + } + } + + /// The blob of `picture` this subject renders: avatars use the thumbnail, a cover the original. + func blobID(for picture: ProfilePicture) -> BlobID { + switch self { + case .user, .chat: picture.thumbnailBlobID + case .cover: picture.blobID } } } @@ -43,6 +54,7 @@ final class ProfileAvatarStore { /// merely turned away could not learn that the fetch it skipped had failed, and nothing retried. @ObservationIgnored private var inFlight: [AvatarSubject: Task] = [:] @ObservationIgnored private let cache: BlobCache + @ObservationIgnored private let coverCache: BlobCache @ObservationIgnored private let mintURL: (BlobID, AvatarSubject) async throws -> URL? @ObservationIgnored private let fetch: (URL) async throws -> Data @@ -52,19 +64,27 @@ final class ProfileAvatarStore { /// - fetch: downloads the bytes at a URL. init( cache: BlobCache = .profilePictures, + coverCache: BlobCache = .profileCovers, mintURL: @escaping (BlobID, AvatarSubject) async throws -> URL?, fetch: @escaping (URL) async throws -> Data = { url in try await URLSession.shared.data(from: url).0 } ) { self.cache = cache + self.coverCache = coverCache self.mintURL = mintURL self.fetch = fetch } - convenience init(flipClient: FlipClient, owner: KeyPair, cache: BlobCache = .profilePictures) { + convenience init( + flipClient: FlipClient, + owner: KeyPair, + cache: BlobCache = .profilePictures, + coverCache: BlobCache = .profileCovers + ) { self.init( cache: cache, + coverCache: coverCache, mintURL: { blobID, subject in try await flipClient.blobDownloadURL( blobID: blobID, @@ -91,16 +111,17 @@ final class ProfileAvatarStore { await load(.user(userID), picture: picture) } - /// Makes a subject's current thumbnail available to ``data(for:)``. + /// Makes a subject's current picture available to ``data(for:)``. /// /// Returns without a round trip when the bytes are already in memory or on disk for that exact /// blob, and joins the fetch already running for that subject rather than starting a second one. func load(_ subject: AvatarSubject, picture: ProfilePicture?) async { - guard let blobID = picture?.thumbnailBlobID else { return } + guard let blobID = picture.map(subject.blobID(for:)) else { return } // A different blob under the same id is a changed picture, not a cache hit. if blobBySubject[subject] == blobID, dataBySubject[subject] != nil { return } + let cache = cache(for: subject) if let cached = cache.data(for: blobID) { store(cached, subject: subject, blobID: blobID) return @@ -122,13 +143,18 @@ final class ProfileAvatarStore { await task.value } + /// Covers hold the full-size blob, so they get their own budget rather than crowding out thumbnails. + private func cache(for subject: AvatarSubject) -> BlobCache { + if case .cover = subject { coverCache } else { cache } + } + /// Download URLs expire, so one is minted per fetch and never stored. private func download(blobID: BlobID, subject: AvatarSubject) async { do { guard let url = try await mintURL(blobID, subject) else { return } let data = try await fetch(url) - cache.write(data, for: blobID) + cache(for: subject).write(data, for: blobID) store(data, subject: subject, blobID: blobID) } catch { guard !Task.isCancelled else { return } diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 324483a24..1941c09bf 100644 --- a/Flipcash/Core/Navigation/AppRouter.swift +++ b/Flipcash/Core/Navigation/AppRouter.swift @@ -63,7 +63,16 @@ final class AppRouter { /// is showing; the topmost-stack mutators (`push`, `popTopmost`, …) fall /// back to this so in-tab navigation lands on the right stack. /// `HomeTabView` keeps it in sync with the selected tab. - var activeTabStack: Stack? + var activeTabStack: Stack? { + didSet { + if activeTabStack != oldValue { isShowingProfileCard = false } + } + } + + /// Whether the You tab's profile card is up. It is a cover local to `YouScreen`, which the + /// sheet stack cannot see, so the router closes it itself whenever sheets are dismissed, a + /// tab is requested or the selected tab changes — otherwise a deep link would land beneath it. + var isShowingProfileCard = false /// A token the wallet should open in its expanded card state, rather than /// as a pushed screen. Deep links use this so following a link lands where @@ -86,7 +95,11 @@ final class AppRouter { /// A tab the router wants brought forward, named by the stack it owns. /// `HomeTabView` selects it and clears this. Deep-link routing only; nothing /// else drives tab selection. - var requestedTabStack: Stack? + var requestedTabStack: Stack? { + didSet { + if requestedTabStack != nil { isShowingProfileCard = false } + } + } /// The stack that `push`/`pop`-style calls target: the presented sheet's /// stack, or — when no sheet is up and a tab is the active surface — the @@ -458,6 +471,7 @@ final class AppRouter { /// re-open instead. func dismissSheet() { guard let dismissing = presentedSheets.popLast() else { return } + isShowingProfileCard = false dismissedStacks.insert(dismissing.stack) logger.info("Dismissed sheet", metadata: [ "sheet": "\(dismissing)", @@ -577,10 +591,10 @@ final class AppRouter { logger.info("Routed to wallet", metadata: ["stack": "\(Stack.balance)"]) } - /// Surfaces the user's own tip card: the You tab at its root. + /// Surfaces the user's own profile: the You tab at its root. /// /// Not expressible as `navigate(to:)` — the You tab's stack is entered by - /// tab selection and has no destination that names the card. + /// tab selection and has no destination that names the profile. /// /// Idempotent, like `navigate(to:)`: the scanner decodes a tipcode every /// frame until its camera tears down, so arriving is not allowed to re-fire diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index dcbc59cec..2ba694e49 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -194,13 +194,11 @@ struct ConversationScreen: View { /// the amount the CTA names is the one the amount screen enforces. private var startChattingFee: FiatAmount? { guard !chatExists, let userID = tipCounterpart?.userID else { return nil } - let currency = ratesController.balanceCurrency - return TipFloor.toOpenDM( - recipientFee: session.cachedUserProfile(for: userID)?.minDmChatInitFee, - presets: session.userFlags?.tipPresets(for: currency), - in: currency, - rates: ratesController.cachedRates - )?.displayed + return StartChattingFee.amount( + for: session.cachedUserProfile(for: userID), + session: session, + ratesController: ratesController + ) } /// Whether a chat exists to hold a transcript. An `existing` conversation diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index bbe034dda..365378658 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -66,15 +66,13 @@ struct HomeTabView: View { /// Hide the tab bar whenever the active tab has pushed a screen (e.g. Wallet → /// Currency Info → Give) so that screen owns the full height, while a /// bill/tipcard is up (the app-root overlay owns the screen then, as the v1 - /// bill replaced the bottom bar), and while the You tab's card is expanded. + /// bill replaced the bottom bar). /// Sheets already cover the bar, so only in-tab pushes need handling here. @State private var cardExpansion = WalletCardExpansion() - @State private var tipCardPresentation = TipCardPresentation() private var isTabBarHidden: Bool { selection.hidesTabBar( isWalletCardExpanded: cardExpansion.isExpanded, - isTipCardExpanded: tipCardPresentation.isExpanded, isShowingBill: sessionContainer.session.isShowingBill, hasPushedScreen: selection.pushStack.map { !router[$0].isEmpty } ?? false ) @@ -102,8 +100,6 @@ struct HomeTabView: View { .onChange(of: selection) { _, tab in router.activeTabStack = tab.pushStack if tab == .chat { hasOpenedChat = true } - // Leaving the tab puts the card back (and the brightness with it). - tipCardPresentation.collapse() } .onDisappear { router.activeTabStack = nil } .onChange(of: router.presentedSheet != nil || sessionContainer.session.isShowingBill, initial: true) { _, covered in @@ -330,7 +326,6 @@ struct HomeTabView: View { } case .tipCard: TipCardTab() - .environment(tipCardPresentation) } } } @@ -358,15 +353,13 @@ extension HomeTab { /// hiding on it everywhere left that tab with no bar and no way back. func hidesTabBar( isWalletCardExpanded: Bool, - isTipCardExpanded: Bool, isShowingBill: Bool, hasPushedScreen: Bool ) -> Bool { if isShowingBill || hasPushedScreen { return true } switch self { case .wallet: return isWalletCardExpanded - case .tipCard: return isTipCardExpanded - case .scan, .chat: return false + case .tipCard, .scan, .chat: return false } } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift index 40b6abe53..6f7790feb 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift @@ -50,7 +50,7 @@ final class TipFlow { // MARK: - Entry - /// Handles a scanned or deeplinked tipcode. Gates in order: own-id codes - /// (routed to the user's own tip card), then a tippable profile (held + + /// (routed to the user's own profile), then a tippable profile (held + /// profile creation presented). /// /// No balance gate: this flow no longer moves money, it opens a chat. The @@ -60,7 +60,7 @@ final class TipFlow { /// conversation they can read and reply in. func begin(userID: UserID) { // Tipping yourself is a payment no-op, so there's no flow to start from - // your own code — show the user their own tip card rather than swallow + // your own code — show the user their own profile rather than swallow // the scan or tap. `showOwnTipCard()` absorbs the repeat calls the // per-frame scanner makes until the camera tears down. guard userID != session.userID else { diff --git a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift new file mode 100644 index 000000000..06376bedc --- /dev/null +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -0,0 +1,172 @@ +// +// ProfileCardScreen.swift +// Flipcash +// + +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashUI + +/// The user's tip card full screen (Figma node 9277:121410), with the copyable public link and the +/// Share/Download pair under it. Presented over the You tab from the profile's Share menu. +struct ProfileCardScreen: View { + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(\.dismiss) private var dismiss + + /// Warmed by `YouScreen`, which outlives this screen, so reopening reuses the render. + let previewCache: TipCodePreviewCache + @State private var isShowingDownloadOptions = false + + /// The format tapped in the download sheet, held until the sheet is gone so + /// the share sheet has a settled controller to present on. + @State private var pendingDownload: TipCardDownloadFormat? + + /// The brightness to put back on close — set only when this screen raised it. + @State private var previousBrightness: CGFloat? + + /// The card's width, from the full-screen frame (302 of the 402pt frame). + private static let maxCardWidth: CGFloat = 302 + private static let horizontalInset: CGFloat = 20 + + /// Below this a code is hard to scan, so the screen is raised to `boostedBrightness`. + private static let minimumScanBrightness: CGFloat = 0.4 + private static let boostedBrightness: CGFloat = 0.6 + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + if let name = displayName { + TipcardView( + size: cardSize, + name: name, + avatar: nil, + codeData: codeData, + tintOpacity: 0.36, + subtitle: username.map(\.handle) + ) + .padding(.top, 24) + } + + TipCardLinkRow(url: url) + .padding(.top, 32) + + HStack(spacing: 10) { + TipCardActionButton(asset: .shareOS, title: "Share", action: shareTipCard) + .accessibilityIdentifier("you-share-button") + + TipCardActionButton(asset: .fileDownload, title: "Download") { + isShowingDownloadOptions = true + } + .accessibilityIdentifier("you-download-button") + } + .padding(.top, 11) + + closeButton + .padding(.top, 24) + } + .padding(.horizontal, Self.horizontalInset) + .containerRelativeFrame(.vertical, alignment: .center) + } + } + .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { + TipCardDownloadSheet( + onSelect: { pendingDownload = $0; isShowingDownloadOptions = false }, + onCancel: { isShowingDownloadOptions = false } + ) + } + .onAppear(perform: boostBrightness) + .onDisappear(perform: restoreBrightness) + } + + private var closeButton: some View { + Button { + dismiss() + } label: { + Text("Close") + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .opacity(0.5) + .padding(.vertical, 12) + .padding(.horizontal, 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + .accessibilityIdentifier("profile-card-close") + } + + // MARK: - Content - + + private var profile: Profile? { sessionContainer.session.profile } + + private var displayName: String? { + guard let name = profile?.displayName, !name.isEmpty else { return nil } + return name + } + + private var username: Username? { profile?.username } + + private var codeData: Data { + TipCode.Payload(userID: sessionContainer.session.userID).codeData() + } + + private var url: URL { .tipcard(for: sessionContainer.session.userID, username: username) } + + private var cardSize: CGSize { + let screenWidth = UIApplication.shared.firstWindowScene?.screen.bounds.width ?? Self.maxCardWidth + let width = min(Self.maxCardWidth, screenWidth - Self.horizontalInset * 2) + return CGSize(width: width, height: width * TipcardView.aspectRatio) + } + + // MARK: - Actions - + + private func shareTipCard() { + let item = TipCodeShareItem.profile( + url: url, + displayName: displayName, + preview: previewCache.preview(for: sessionContainer.session.userID) + ) + ShareSheet.present(activityItem: item) { _ in } + } + + /// Exports the format the sheet picked and hands the file to the share + /// sheet, which is where iOS puts "Save to Files" and every other + /// destination. + /// + /// Runs on the download sheet's dismissal rather than its tap: the share + /// sheet presents on the top-most view controller, which is the download + /// sheet itself until that dismissal finishes. + private func exportPendingDownload() { + guard let format = pendingDownload else { return } + pendingDownload = nil + + guard let file = TipCardExport.file(for: format, codeData: codeData, name: displayName) else { + return + } + + ShareSheet.present(activityItems: [file]) { _ in + // The sheet has taken its copy by now, whether or not the user went + // through with it. + TipCardExport.discard(file) + } + } + + // MARK: - Brightness - + + /// The card is a code someone is about to scan, so a dim screen is raised for as long as it is up. + private func boostBrightness() { + guard let screen = UIApplication.shared.firstWindowScene?.screen, + screen.brightness < Self.minimumScanBrightness else { return } + previousBrightness = previousBrightness ?? screen.brightness + screen.brightness = Self.boostedBrightness + } + + private func restoreBrightness() { + guard let previousBrightness, let screen = UIApplication.shared.firstWindowScene?.screen else { return } + screen.brightness = previousBrightness + self.previousBrightness = nil + } +} diff --git a/Flipcash/Core/Screens/Main/You/TipCardExport.swift b/Flipcash/Core/Screens/Main/You/TipCardExport.swift index 84a52dcd2..6d74d8db0 100644 --- a/Flipcash/Core/Screens/Main/You/TipCardExport.swift +++ b/Flipcash/Core/Screens/Main/You/TipCardExport.swift @@ -92,7 +92,7 @@ enum TipCardExport { // MARK: - Naming - - /// `Tip Ada.svg` — the name the share sheet shows and the file the user + /// `Chat with Ada.svg` — the name the share sheet shows and the file the user /// ends up with. Path separators are dropped so a display name can't steer /// where the file is written. private static func filename(for format: TipCardDownloadFormat, name: String?) -> String { @@ -101,7 +101,7 @@ enum TipCardExport { .replacingOccurrences(of: ":", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) - let stem = if let subject, !subject.isEmpty { "Tip \(subject)" } else { "Tip Card" } + let stem = if let subject, !subject.isEmpty { "Chat with \(subject)" } else { "Tip Card" } return "\(stem).\(format.rawValue)" } diff --git a/Flipcash/Core/Screens/Main/You/TipCardPresentation.swift b/Flipcash/Core/Screens/Main/You/TipCardPresentation.swift deleted file mode 100644 index 3ce37888d..000000000 --- a/Flipcash/Core/Screens/Main/You/TipCardPresentation.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// TipCardPresentation.swift -// Flipcash -// - -import UIKit -import Observation - -/// Whether the You tab's tip card is expanded to fill the screen. -/// -/// The card expands inside the page rather than as a presented screen, so the -/// tab bar's usual "hide while a stack is non-empty" rule cannot see it; the You -/// tab reports it here for `HomeTabView` to read. Owns the screen-brightness -/// boost too, since an expanded card is a code someone is about to scan. -@MainActor -@Observable -final class TipCardPresentation { - - private(set) var isExpanded = false - - /// The brightness to put back on collapse — set only when we raised it. - @ObservationIgnored private var previousBrightness: CGFloat? - - /// Below this a code is hard to scan, so the screen is raised to `boosted`. - private static let minimumScanBrightness: CGFloat = 0.4 - private static let boostedBrightness: CGFloat = 0.6 - - func expand() { - guard !isExpanded else { return } - isExpanded = true - boostBrightness() - } - - func collapse() { - guard isExpanded else { return } - isExpanded = false - restoreBrightness() - } - - // MARK: - Brightness - - - private var screen: UIScreen? { - UIApplication.shared.firstWindowScene?.screen - } - - private func boostBrightness() { - guard let screen, screen.brightness < Self.minimumScanBrightness else { return } - if previousBrightness == nil { - previousBrightness = screen.brightness - } - screen.brightness = Self.boostedBrightness - } - - private func restoreBrightness() { - guard let previousBrightness, let screen else { return } - screen.brightness = previousBrightness - self.previousBrightness = nil - } -} diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index fd00a960e..5ebc56671 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -8,83 +8,35 @@ import UIKit import FlipcashCore import FlipcashUI -/// The "You" tab (Figma node 9276:4634): the user's tip card, a "Full Screen" -/// affordance, the copyable public link, the Share/Download pair, and the -/// gear that pushes Settings. +/// The "You" tab: the user's own profile — cover, avatar, name, handle and bio — with the stats +/// card, an Edit Profile button, and the finish-your-profile checklist. /// -/// Tapping the card (or "Full Screen") expands it here on the page: everything -/// under it slides out, the tab bar hides, and the card grows into the middle of -/// the screen with a close button at the bottom. The page owns that transition — -/// nothing is pushed or presented — so the card never leaves this screen. -/// Pulling the expanded card up walks it back to its slot under the finger, so -/// it can be put away without reaching for the close button. -/// Settings pushes onto the tab's `.you` stack, so it never touches the v1 -/// scanner's Settings sheet. +/// The banner carries two controls: Share, whose menu shares the profile link or presents the +/// tip card full screen (`ProfileCardScreen`), and the gear that pushes Settings. Settings pushes +/// onto the tab's `.you` stack, so it never touches the v1 scanner's Settings sheet. /// -/// A profile with no display name has no card: the page then shows the -/// add-your-name invitation in the card's place and drops the link and share -/// affordances, but still renders — the gear is this account's only way to -/// reach Settings, and with it Log Out. +/// A profile with no display name has no card: the page then shows the add-your-name invitation +/// in place of the name block and drops Share, but still renders — the gear is this account's only +/// way to reach Settings, and with it Log Out. struct YouScreen: View { @Environment(SessionContainer.self) private var sessionContainer @Environment(AppRouter.self) private var router - @Environment(TipCardPresentation.self) private var presentation + @Environment(RatesController.self) private var ratesController /// Warms the share-sheet preview image ahead of the share tap so it never /// lands on the tap; keyed by user. @State private var previewCache = TipCodePreviewCache() - @State private var isShowingDownloadOptions = false - /// The format tapped in the download sheet, held until the sheet is gone so - /// the share sheet has a settled controller to present on. - @State private var pendingDownload: TipCardDownloadFormat? - - /// The balance gate, raised when the card is tapped below the minimum. + /// The balance gate, raised when the claim row is tapped below the minimum. @State private var usernameDialog: DialogItem? /// The top safe-area inset, which sizes the hand-drawn top fade. @State private var safeAreaTop: CGFloat = 0 - /// The card's slot in the page's layout, in global space — where the card - /// sits before it travels to the middle of the screen. - @State private var cardSlotFrame: CGRect = .zero - - /// The live pull on the expanded card, negative upward — the card's travel - /// home, scrubbed by hand until the finger lets go. - @State private var dragTranslation: CGFloat = 0 - - /// The on-screen card width, from the Figma placement: node 9276:4641 draws - /// the You card 241.6 wide. - private static let cardWidth: CGFloat = 242 - /// The expanded card's width, per the full-screen frame (Figma node - /// 9277:121410 — 302 of the 402pt frame). - private static let maxExpandedCardWidth: CGFloat = 302 - /// The page's horizontal inset, also the expanded card's minimum margin. + /// The page's horizontal inset. private static let horizontalInset: CGFloat = 20 - private static let expansion: Animation = .spring(response: 0.45, dampingFraction: 0.85) - - /// Puts a pull that fell short back where it started. Shorter than - /// `expansion` — the card has barely moved, and a long settle reads as a stall. - private static let settle: Animation = .spring(response: 0.3, dampingFraction: 0.85) - - /// How much of the card's travel home a pull has to cover to finish the job. - private static let collapseThreshold: CGFloat = 0.3 - - /// How much of that travel a flick has to project onto instead — a short, - /// fast swipe closes the card even though it never covered the distance. - private static let flickThreshold: CGFloat = 0.75 - - /// The fraction of a downward drag the card follows. Down is where expanding - /// already put the card, so the pull has nowhere to take it and gives only - /// enough to show the drag is being felt. - private static let overdragResistance: CGFloat = 0.25 - - /// The slack the drag recogniser swallows before it fires, handed back so - /// the card picks up from where it stands instead of jumping that distance. - private static let dragActivationSlack: CGFloat = 10 - /// The gap the page keeps between its last row and the tab bar. private static let tabBarGap: CGFloat = 24 @@ -104,138 +56,154 @@ struct YouScreen: View { var body: some View { Background(color: .backgroundMain) { - ZStack { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - cardSection - .padding(.top, 64) - - pageContent - // The expanded card owns the screen: everything under - // it slides down and out — as the tab bar does, which - // `HomeTabView` hides on the same state. Opacity and - // offset (not removal) keep the layout stable so - // nothing reflows on the way back. - // - // Bound to the card being expanded, not to how far a - // pull has taken it back: the card is translucent and - // sits over this content until it is nearly home, so - // anything faded in under way shows through it. - .opacity(isExpanded ? 0 : 1) - .offset(y: isExpanded ? 60 : 0) - .allowsHitTesting(!isExpanded) + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + header + .padding(.top, 8) + + if displayName == nil { + setupPrompt + .padding(.top, 24) + } else { + ProfileStatsCard( + minimumToChat: StartChattingFee.amount( + for: profile, + session: sessionContainer.session, + ratesController: ratesController + ), + joinedAt: profile?.joinedAt + ) + .padding(.top, 24) + + // The Edit Profile slice repoints this to `.editProfile`. + Button("Edit Profile") { + router.push(.settings) + } + .buttonStyle(.filled05) + .padding(.top, 12) + .accessibilityIdentifier("you-edit-profile") + } + + if profileTutorialState.isVisible { + TutorialChecklistCard( + title: "Finish Your Profile", + items: profileTutorialState.items, + onTap: handleProfileTutorialTap + ) + .padding(.top, 32) + .accessibilityIdentifier("you-profile-tutorial-card") } - .padding(.horizontal, Self.horizontalInset) - .padding(.bottom, bottomContentInset) - } - .scrollDisabled(isExpanded) - // The app-wide soft edge effect is drawn from a bar's own - // background, and this tab has no navigation bar at all, so it - // covers the bottom (tab bar) but not the top — what scrolls up - // into the status bar has to be faded by hand. Same treatment as - // `WalletScreen`, the app's other bar-less tab. - .overlay(alignment: .top) { - LinearGradient( - colors: [Color.backgroundMain, Color.backgroundMain.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: safeAreaTop + Self.topFadeLength) - .ignoresSafeArea(edges: .top) - .allowsHitTesting(false) - } - .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { safeAreaTop = $0 } - - // Sibling of the scroll view, not an overlay on it: the ZStack - // already sits below the status bar, so no `safeAreaTop` offset. - settingsGear - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .opacity(isExpanded ? 0 : 1) - .allowsHitTesting(!isExpanded) - - if isExpanded { - closeButton - .transition(.opacity) - .opacity(1 - collapseProgress) } + .padding(.horizontal, Self.horizontalInset) + .padding(.bottom, bottomContentInset) + } + // The app-wide soft edge effect is drawn from a bar's own + // background, and this tab has no navigation bar at all, so it + // covers the bottom (tab bar) but not the top — what scrolls up + // into the status bar has to be faded by hand. Same treatment as + // `WalletScreen`, the app's other bar-less tab. + .overlay(alignment: .top) { + LinearGradient( + colors: [Color.backgroundMain, Color.backgroundMain.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: safeAreaTop + Self.topFadeLength) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) } - // On the whole page rather than the card: expanded, the card is the - // page, and a pull that has to land on it exactly is a pull that - // misses. Nothing here scrolls while the card is up, so there is no - // scroll for the drag to fight. - .simultaneousGesture(collapseDrag, isEnabled: isExpanded) + .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { safeAreaTop = $0 } } - .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { - TipCardDownloadSheet( - onSelect: { pendingDownload = $0; isShowingDownloadOptions = false }, - onCancel: { isShowingDownloadOptions = false } - ) + .fullScreenCover(isPresented: Bindable(router).isShowingProfileCard) { + ProfileCardScreen(previewCache: previewCache) } .task(id: profilePicture?.thumbnailBlobID) { // There is no card to share, and so no preview worth rendering, until - // the profile has a name. - guard displayName != nil else { return } - previewCache.warm(TipCode.Payload(userID: sessionContainer.session.userID)) + // the profile has a name. Warmed before the avatar download so a slow + // network cannot hold it back. + if displayName != nil { + previewCache.warm(TipCode.Payload(userID: sessionContainer.session.userID)) + } + await sessionContainer.profileAvatars.load(userID: sessionContainer.session.userID, picture: profilePicture) + } + // A cash link raises the bill without touching the router, and it would draw under the card. + .onChange(of: sessionContainer.session.isShowingBill) { _, isShowing in + if isShowing { router.isShowingProfileCard = false } } .dialog(item: $usernameDialog) } - // MARK: - Card - + // MARK: - Header - - /// The card plus its "Full Screen" caption. Tapping either expands the card; - /// tapping the expanded card puts it back. - /// - /// A profile without a display name has no card to draw, so the slot carries - /// the invitation to add one instead. The rest of the page stays where it is - /// either way — the gear included, which is the only route to logging out. - @ViewBuilder - private var cardSection: some View { - if let name = displayName { - VStack(spacing: 24) { - // The slot holds the card's place in the page while the card - // itself is offset into the middle of the screen — measuring the - // slot rather than the card keeps the measurement free of the - // offset it feeds. - // - // It keeps its collapsed size throughout: a slot that grew with - // the card would move its own centre mid-flight, and that centre - // is what `cardOffset` measures from, so the card would jump - // against its own travel. Fixed, the card grows about a still - // centre and the page below never reflows. - Color.clear - .frame(width: Self.collapsedCardSize.width, height: Self.collapsedCardSize.height) - .overlay { - TipcardView( - size: Self.drawnCardSize, - name: name, - avatar: nil, - codeData: codeData, - tintOpacity: 0.36, - subtitle: username.map(\.handle) - ) - .scaleEffect(cardScale) - .offset(y: cardOffset) + private var header: some View { + ProfileHeaderView( + userID: sessionContainer.session.userID, + displayName: displayName, + handle: username.map(\.handle), + bio: profile?.bio, + avatarData: sessionContainer.profileAvatars.data(for: sessionContainer.session.userID), + avatarBlurhash: profilePicture?.thumbnailBlurhash, + coverPicture: profile?.coverPicture, + customization: profile?.tipCardCustomization, + statusChip: nil, + bannerActions: { + HStack(spacing: 8) { + if displayName != nil { + shareMenu } - .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { cardSlotFrame = $0 } - - // Sits directly under the card, which covers it until the very - // end of the pull — so it waits for the card to be home rather - // than fading in through it. - Self.caption("Full Screen", pointsUp: false) - .opacity(isExpanded ? 0 : 1) + settingsGear + } + }, + underHandle: { + if shouldPromptForUsername { + Button(action: claimUsername) { + Text("Claim your username ›") + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .accessibilityIdentifier("you-claim-username") + } } - .contentShape(Rectangle()) - .onTapGesture(perform: toggleExpanded) - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilityLabel(isExpanded ? "Close tip card" : "View tip card full screen") - .accessibilityIdentifier("you-fullscreen-button") - } else { - setupPrompt + ) + } + + private var shareMenu: some View { + Menu { + Button("Share Profile", action: shareTipCard) + Button("Show Profile Card") { router.isShowingProfileCard = true } + } label: { + bannerControl(systemName: "square.and.arrow.up") } + .accessibilityLabel("Share") + .accessibilityIdentifier("you-share") } - /// Stands in for the card until the profile has a display name: the card the + private var settingsGear: some View { + Button { + router.push(.settings) + } label: { + bannerControl(systemName: "gearshape") + } + .buttonStyle(.plain) + .accessibilityLabel("Settings") + .accessibilityIdentifier("you-settings") + } + + /// A glyph on a dark disc, legible over any cover picture. + private func bannerControl(systemName: String) -> some View { + Image(systemName: systemName) + .font(.system(size: 16)) + .foregroundStyle(Color.white) + .frame(width: 36, height: 36) + .background(Color.black.opacity(0.4), in: Circle()) + .contentShape(Circle()) + } + + // MARK: - No name - + + /// Stands in for the name block until the profile has a display name: the card the /// user is about to get, blurred out behind the invitation to claim it, so /// the slot shows what is on offer rather than sitting empty. /// @@ -246,7 +214,7 @@ struct YouScreen: View { // A stand-in name, never read: it only has to give the blur a card // shaped like the one the user gets. TipcardView( - size: Self.collapsedCardSize, + size: Self.placeholderCardSize, name: Self.placeholderName, avatar: nil, codeData: codeData, @@ -294,7 +262,7 @@ struct YouScreen: View { } // Held inside the card it sits on, so the copy reads as part of the // card rather than spilling past its edges. - .frame(maxWidth: Self.collapsedCardSize.width - 16) + .frame(maxWidth: Self.placeholderCardSize.width - 16) } } @@ -305,119 +273,20 @@ struct YouScreen: View { /// The placeholder card's outline, matching `TipcardView`'s own corner /// rounding — which it derives from the card's width. private static let placeholderShape = RoundedRectangle( - cornerRadius: collapsedCardSize.width * 0.08, + cornerRadius: placeholderCardSize.width * 0.08, style: .continuous ) - /// The expanded card's collapse control (Figma node 9276:4601): the same - /// caption as "Full Screen", chevron flipped, sitting at the bottom of the - /// screen where the tab bar was. - private var closeButton: some View { - VStack { - Spacer() - - Button(action: toggleExpanded) { - Self.caption("Close", pointsUp: true) - .padding(.vertical, 12) - .padding(.horizontal, 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") - .accessibilityIdentifier("you-close-fullscreen-button") - } - .padding(.bottom, 8) - } - - /// The dimmed caption-and-chevron pair used both to expand the card and to - /// put it back. - private static func caption(_ title: String, pointsUp: Bool) -> some View { - HStack(spacing: 5) { - Text(title) - .font(.appTextSmall) - Image.system(.chevronDown) - .renderingMode(.template) - .frame(width: 16, height: 16) - .rotationEffect(.degrees(pointsUp ? 180 : 0)) - } - .foregroundStyle(Color.textMain) - .opacity(0.5) - } - - // MARK: - Settings gear - - - private var settingsGear: some View { - Button { - router.push(.settings) - } label: { - Image(systemName: "gearshape") - .font(.system(size: 20)) - .foregroundStyle(Color.textMain) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(.top, 4) - .padding(.trailing, 8) - .accessibilityLabel("Settings") - .accessibilityIdentifier("you-settings") - } - - // MARK: - Page - - - /// Everything below the card — the part that clears out when the card expands. - /// - /// The link and the Share/Download pair all address a card that a name-less - /// profile does not have, so they sit out until it does. The checklist - /// shows either way, since naming the profile is one of its chores. - private var pageContent: some View { - VStack(spacing: 0) { - if profileTutorialState.isVisible { - TutorialChecklistCard( - title: "Finish Your Profile", - items: profileTutorialState.items, - onTap: handleProfileTutorialTap - ) - .padding(.top, 32) - .accessibilityIdentifier("you-profile-tutorial-card") - - // Fences the checklist off from the block below it — the two are - // unrelated chores that would otherwise read as one list. - Color.rowSeparator - .frame(height: 1) - .padding(.top, 20) - } - - if displayName != nil { - TipCardLinkRow(url: url) - .padding(.top, profileTutorialState.isVisible ? 20 : 70) - - HStack(spacing: 10) { - TipCardActionButton(asset: .shareOS, title: "Share", action: shareTipCard) - .accessibilityIdentifier("you-share-button") - - TipCardActionButton(asset: .fileDownload, title: "Download") { - isShowingDownloadOptions = true - } - .accessibilityIdentifier("you-download-button") - } - .padding(.top, 11) - - if shouldPromptForUsername { - usernameProgressCard - .padding(.top, 11) - .accessibilityIdentifier("you-username-progress-card") - } - } - } - } + /// The placeholder card's size, which gives the blur a card shaped like the one the user gets. + private static let placeholderCardSize = CGSize( + width: 242, + height: 242 * TipcardView.aspectRatio + ) - // MARK: - Username progress - + // MARK: - Username claim - /// The balance's standing against the username minimum. Read once per - /// render and switched on here rather than re-derived at tap time — the - /// card's tappability and this switch's `.proceed` branch already agree - /// on the same value. + /// render and switched on here rather than re-derived at tap time. private var usernameProgress: UsernameGate { usernameGate( session: sessionContainer.session, @@ -425,32 +294,8 @@ struct YouScreen: View { ) } - @ViewBuilder - private var usernameProgressCard: some View { - switch usernameProgress { - case .proceed: - UsernameProgressCard( - state: .complete, - subtitle: "Tap to select your username now", - trailing: nil, - fraction: 1, - action: beginUsernameClaim - ) - case .addMoney(let minimum, let shortfall, let fraction): - UsernameProgressCard( - state: .incomplete, - subtitle: "Get your balance to \(minimum.formatted(minimumFractionDigits: 0)) \(minimum.currency.rawValue.uppercased()) or more to unlock", - trailing: "\(shortfall.formatted(minimumFractionDigits: 0)) \(shortfall.currency.rawValue.uppercased()) to go", - fraction: fraction, - action: { presentBalanceGate(minimum: minimum) } - ) - } - } - // MARK: - Content - - private var isExpanded: Bool { presentation.isExpanded } - private var profile: Profile? { sessionContainer.session.profile } private var profilePicture: ProfilePicture? { profile?.profilePicture } private var profileTutorialState: ProfileTutorialState { .init(profile: profile) } @@ -479,125 +324,8 @@ struct YouScreen: View { private var url: URL { .tipcard(for: sessionContainer.session.userID, username: username) } - // MARK: - Card geometry - - - /// The card's size in the page — also the slot's size at every point in the - /// expansion, so the page's layout is the same expanded as collapsed. - private static let collapsedCardSize = CGSize( - width: cardWidth, - height: cardWidth * TipcardView.aspectRatio - ) - - /// The size the card is always drawn at, whatever it currently measures on - /// screen — the widest it ever gets, so scaling it to size only ever - /// samples the drawing down. - private static let drawnCardSize = CGSize( - width: maxExpandedCardWidth, - height: maxExpandedCardWidth * TipcardView.aspectRatio - ) - - /// The expansion runs as one scale on the drawn card rather than a new size - /// for it to lay itself out at. Laid out, each of the card's metrics is a - /// separate animatable value — and the name's font size is not animatable - /// at all — so the parts arrived at their new sizes at different moments - /// and overlapped mid-flight. Scaled, the card travels as one figure. - private var cardScale: CGFloat { - guard isExpanded else { return Self.cardWidth / Self.drawnCardSize.width } - // Mid-pull the card sits between its two widths, in step with everything - // else the pull drives. - let width = expandedCardWidth + (Self.cardWidth - expandedCardWidth) * collapseProgress - return width / Self.drawnCardSize.width - } - - /// The expanded width: the design's 302, narrowed only if the screen can't - /// hold it inside the page's margins. - private var expandedCardWidth: CGFloat { - guard screenBounds.width > 0 else { return Self.maxExpandedCardWidth } - return min(Self.maxExpandedCardWidth, screenBounds.width - Self.horizontalInset * 2) - } - - /// The whole screen, safe areas included — what the design centres the - /// expanded card in. Read from the window rather than a geometry proxy, - /// which reports the safe-area rect instead; `BillOverlayView` centres the - /// full-screen bill the same way. - private var screenBounds: CGRect { - UIApplication.shared.firstWindowScene?.screen.bounds ?? .zero - } - - /// The gap between the card's slot's centre and the screen's: how far the - /// card moves to sit in the middle of the screen, and so how far a pull has - /// to bring it back. - private var expandedTravel: CGFloat { - guard !cardSlotFrame.isEmpty, !screenBounds.isEmpty else { return 0 } - return screenBounds.midY - cardSlotFrame.midY - } - - /// How far the card currently sits from its slot. - private var cardOffset: CGFloat { - guard isExpanded else { return 0 } - return expandedTravel * (1 - collapseProgress) + overdrag - } - - /// How far a downward drag pushes the card past its expanded resting place. - private var overdrag: CGFloat { - max(0, dragTranslation) * Self.overdragResistance - } - - /// How far the pull has taken the card back toward its slot: 0 where - /// expanding left it, 1 once it is home. The card's place, its size and the - /// close control all read it, so they run backwards under the finger and - /// hold wherever the finger holds. - private var collapseProgress: CGFloat { - guard isExpanded, dragTranslation < 0, expandedTravel > 0 else { return 0 } - return min(1, -dragTranslation / expandedTravel) - } - // MARK: - Actions - - /// Puts the expanded card back by pulling it toward its slot — upward, - /// where the card came from and where the "Close" chevron points. The card - /// shrinks back along its travel under the finger, the close control fading - /// with it, and lets go once the pull has covered enough of the way home or - /// flicked hard enough to have got there. A downward pull, which has nowhere - /// to take the card, is met with resistance instead. - private var collapseDrag: some Gesture { - DragGesture(minimumDistance: Self.dragActivationSlack) - .onChanged { drag in - let translation = drag.translation.height - dragTranslation = translation < 0 - ? min(0, translation + Self.dragActivationSlack) - : max(0, translation - Self.dragActivationSlack) - } - .onEnded(endCollapseDrag) - } - - private func endCollapseDrag(_ drag: DragGesture.Value) { - let travel = expandedTravel - let closes = travel > 0 && ( - -dragTranslation >= travel * Self.collapseThreshold - || -drag.predictedEndTranslation.height >= travel * Self.flickThreshold - ) - - withAnimation(closes ? Self.expansion : Self.settle) { - // Cleared in the same transaction as the collapse it may carry, so - // the card carries on from where the finger left it either way. - dragTranslation = 0 - if closes { - presentation.collapse() - } - } - } - - private func toggleExpanded() { - withAnimation(Self.expansion) { - if isExpanded { - presentation.collapse() - } else { - presentation.expand() - } - } - } - private func shareTipCard() { let item = TipCodeShareItem.profile( url: url, @@ -607,35 +335,17 @@ struct YouScreen: View { ShareSheet.present(activityItem: item) { _ in } } - /// Exports the format the sheet picked and hands the file to the share - /// sheet, which is where iOS puts "Save to Files" and every other - /// destination. - /// - /// Runs on the download sheet's dismissal rather than its tap: the share - /// sheet presents on the top-most view controller, which is the download - /// sheet itself until that dismissal finishes. - private func exportPendingDownload() { - guard let format = pendingDownload else { return } - pendingDownload = nil - - guard let file = TipCardExport.file(for: format, codeData: codeData, name: displayName) else { - return - } - - ShareSheet.present(activityItems: [file]) { _ in - // The sheet has taken its copy by now, whether or not the user went - // through with it. - TipCardExport.discard(file) + /// Opens the claim screen once the balance clears the minimum, and the + /// balance gate until then. + private func claimUsername() { + switch usernameProgress { + case .proceed: + router.push(.username(username)) + case .addMoney(let minimum, _, _): + presentBalanceGate(minimum: minimum) } } - /// Opens the claim screen. Only ever wired to `usernameProgressCard`'s tap - /// in its `.complete` state, which already means `usernameProgress` is - /// `.proceed` — there is nothing left here to branch on. - private func beginUsernameClaim() { - router.push(.username(username)) - } - private func handleProfileTutorialTap(_ item: ProfileTutorialItem) { switch item { case .displayName: @@ -647,9 +357,8 @@ struct YouScreen: View { } } - /// The `.incomplete` card's tap: names the minimum and offers the way to - /// meet it. The card draws the shortfall but not the reason for it, so the - /// dialog is where the squatting rule gets stated. + /// Names the minimum and offers the way to meet it. The row draws no + /// shortfall, so the dialog is where the squatting rule gets stated. private func presentBalanceGate(minimum: FiatAmount) { usernameDialog = .usernameMinimumBalance(minimum: minimum) { router.presentAddMoney(.general, source: .usernameShortfall) diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift new file mode 100644 index 000000000..9fe5ec8e1 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -0,0 +1,60 @@ +// +// ProfileCoverBanner.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// A profile's 3:1 cover: the picture once it has loaded, the blurhash while it does, and the +/// profile-card colour when there is no picture. +struct ProfileCoverBanner: View { + + @Environment(SessionContainer.self) private var sessionContainer + + let userID: UserID + let coverPicture: ProfilePicture? + let customization: TipCardCustomization? + @ViewBuilder let actions: () -> Actions + + private static var aspectRatio: CGFloat { 3 } + + var body: some View { + Color.clear + .aspectRatio(Self.aspectRatio, contentMode: .fit) + .overlay { cover } + .overlay(alignment: .topTrailing) { + actions() + .padding(8) + } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .task(id: coverPicture?.blobID) { + await sessionContainer.profileAvatars.load(.cover(userID), picture: coverPicture) + } + // `.contain` keeps the Share and Settings buttons as their own elements; a bare + // identifier here would overwrite theirs. + .accessibilityElement(children: .contain) + .accessibilityIdentifier("profile-cover") + } + + @ViewBuilder + private var cover: some View { + if let coverPicture { + let data = sessionContainer.profileAvatars.data(for: .cover(userID)) + if let data, let image = ContactAvatarCache.shared.image(forKey: "cover-\(coverPicture.blobID)", data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else if let preview = BlurHashCache.shared.image(for: coverPicture.thumbnailBlurhash) { + Image(uiImage: preview) + .resizable() + .scaledToFill() + } else { + ProfileCoverFill.color(for: customization) + } + } else { + ProfileCoverFill.color(for: customization) + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift b/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift new file mode 100644 index 000000000..baf76140b --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileCoverFill.swift @@ -0,0 +1,20 @@ +// +// ProfileCoverFill.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The solid colour a cover banner shows when the profile has no cover picture. +enum ProfileCoverFill { + + /// The card tone used when a profile carries no colour, or one that doesn't parse. + static let fallback = Color.backgroundRow + + /// The customization's colour, or ``fallback`` when it is absent or not a `#RRGGBB` hex. + static func color(for customization: TipCardCustomization?) -> Color { + customization.flatMap { Color(hex: $0.colorHex) } ?? fallback + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift new file mode 100644 index 000000000..50cc54705 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -0,0 +1,83 @@ +// +// ProfileHeaderView.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// A profile's top block: cover, avatar overlapping it, name, handle and bio. +/// +/// A nil `displayName` leaves the name block out, for a profile that has not named itself yet. +struct ProfileHeaderView: View { + + let userID: UserID + let displayName: String? + let handle: String? + let bio: String? + let avatarData: Data? + let avatarBlurhash: String? + let coverPicture: ProfilePicture? + let customization: TipCardCustomization? + let statusChip: AnyView? + @ViewBuilder let bannerActions: () -> BannerActions + @ViewBuilder let underHandle: () -> UnderHandle + + private static var avatarSize: CGFloat { 88 } + + var body: some View { + VStack(spacing: 0) { + ProfileCoverBanner( + userID: userID, + coverPicture: coverPicture, + customization: customization, + actions: bannerActions + ) + + ContactAvatarView( + id: userID.uuidString, + displayName: displayName ?? "", + imageData: avatarData, + blurhash: avatarBlurhash, + size: Self.avatarSize + ) + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 4) } + .padding(.top, -Self.avatarSize / 2) + + if let displayName { + VStack(spacing: 4) { + Text(displayName) + .font(.appDisplaySmall) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.center) + .accessibilityIdentifier("profile-name") + + if let handle { + Text(handle) + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .accessibilityIdentifier("profile-handle") + } + + underHandle() + } + .padding(.top, 12) + + if let statusChip { + statusChip + .padding(.top, 8) + } + + if let bio, !bio.isEmpty { + Text(bio) + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.center) + .padding(.top, 12) + .accessibilityIdentifier("profile-bio") + } + } + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift new file mode 100644 index 000000000..39ee5766b --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift @@ -0,0 +1,50 @@ +// +// ProfileStatsCard.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The two figures under a profile header: what it costs to start a chat, and when the user joined. +struct ProfileStatsCard: View { + + let minimumToChat: FiatAmount? + let joinedAt: Date? + + var body: some View { + HStack(spacing: 0) { + stat( + title: "Minimum to Chat", + value: minimumToChat?.formatted() ?? "—", + identifier: "profile-stat-minimum" + ) + + Color.rowSeparator + .frame(width: 1, height: 32) + + stat( + title: "Date Joined", + value: joinedAt?.formatted(.dateTime.month(.abbreviated).year()) ?? "—", + identifier: "profile-stat-joined" + ) + } + .padding(.vertical, 16) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private func stat(title: String, value: String, identifier: String) -> some View { + VStack(spacing: 4) { + Text(value) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Text(title) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityIdentifier(identifier) + } +} diff --git a/Flipcash/Core/Screens/Profile/StartChattingFee.swift b/Flipcash/Core/Screens/Profile/StartChattingFee.swift new file mode 100644 index 000000000..b92e3c4d5 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/StartChattingFee.swift @@ -0,0 +1,41 @@ +// +// StartChattingFee.swift +// Flipcash +// + +import FlipcashCore + +/// What it costs to open a chat with a user, as their profile and the conversation CTA name it. +/// +/// Derived from the same inputs as `SendAmountViewModel.tipFloor(in:)`, so the amount shown is the +/// one the amount screen enforces. +enum StartChattingFee { + + /// The fee `recipientFee` charges, restated in `currency` and rounded up, falling back to the + /// regional preset minimum. Nil when neither is known. + static func amount( + recipientFee: FiatAmount?, + presets: UserFlags.TipPresets?, + currency: CurrencyCode, + rates: [CurrencyCode: Rate] + ) -> FiatAmount? { + TipFloor.toOpenDM( + recipientFee: recipientFee, + presets: presets, + in: currency, + rates: rates + )?.displayed + } + + /// The fee for `profile`, in the user's balance currency. + @MainActor + static func amount(for profile: Profile?, session: Session, ratesController: RatesController) -> FiatAmount? { + let currency = ratesController.balanceCurrency + return amount( + recipientFee: profile?.minDmChatInitFee, + presets: session.userFlags?.tipPresets(for: currency), + currency: currency, + rates: ratesController.cachedRates + ) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift index 9bba3e255..cfb1e3958 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift @@ -219,7 +219,7 @@ extension ErrorBlob: ServerError { /// The surface a blob read is authorized through, for blobs the caller does /// not own. See `flipcash.blob.v1.AccessContext`. -public enum BlobAccessContext: Sendable { +public enum BlobAccessContext: Sendable, Equatable { /// Reading a rendition of `userID`'s current profile picture. case userProfile(UserID) diff --git a/FlipcashCore/Sources/FlipcashCore/Images/BlobCache.swift b/FlipcashCore/Sources/FlipcashCore/Images/BlobCache.swift index b5f0b0fc3..8532063bd 100644 --- a/FlipcashCore/Sources/FlipcashCore/Images/BlobCache.swift +++ b/FlipcashCore/Sources/FlipcashCore/Images/BlobCache.swift @@ -37,6 +37,10 @@ public final class BlobCache: @unchecked Sendable { /// the directory needs a ceiling; the coldest entries go first. public static let profilePictures = BlobCache(name: "Avatars", limitBytes: 24 * 1024 * 1024) + /// Full-size profile covers. Kept apart from ``profilePictures`` so a few megabyte banners + /// cannot evict the avatar thumbnails. + public static let profileCovers = BlobCache(name: "Covers", limitBytes: 48 * 1024 * 1024) + /// The parent of every kind's directory. /// /// Having one lets ``clearAll()`` delete the lot without a registry of kinds to keep in sync. A diff --git a/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift b/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift index 8cbe82b52..476298cbe 100644 --- a/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift +++ b/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift @@ -14,32 +14,20 @@ struct HomeTabBarVisibilityTests { func walletCard_hidesOnlyOnWallet(tab: HomeTab) { let hidden = tab.hidesTabBar( isWalletCardExpanded: true, - isTipCardExpanded: false, isShowingBill: false, hasPushedScreen: false ) #expect(hidden == (tab == .wallet)) } - @Test("an expanded tip card hides the bar only on the You tab", arguments: HomeTab.allCases) - func tipCard_hidesOnlyOnYou(tab: HomeTab) { - let hidden = tab.hidesTabBar( - isWalletCardExpanded: false, - isTipCardExpanded: true, - isShowingBill: false, - hasPushedScreen: false - ) - #expect(hidden == (tab == .tipCard)) - } - @Test("a bill or a pushed screen hides the bar on every tab", arguments: HomeTab.allCases) func billOrPush_hidesEverywhere(tab: HomeTab) { - #expect(tab.hidesTabBar(isWalletCardExpanded: false, isTipCardExpanded: false, isShowingBill: true, hasPushedScreen: false)) - #expect(tab.hidesTabBar(isWalletCardExpanded: false, isTipCardExpanded: false, isShowingBill: false, hasPushedScreen: true)) + #expect(tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: true, hasPushedScreen: false)) + #expect(tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: false, hasPushedScreen: true)) } @Test("nothing open shows the bar", arguments: HomeTab.allCases) func idle_shows(tab: HomeTab) { - #expect(!tab.hidesTabBar(isWalletCardExpanded: false, isTipCardExpanded: false, isShowingBill: false, hasPushedScreen: false)) + #expect(!tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: false, hasPushedScreen: false)) } } diff --git a/FlipcashTests/Navigation/YouTabRoutingTests.swift b/FlipcashTests/Navigation/YouTabRoutingTests.swift index d50055124..a5a11e1ef 100644 --- a/FlipcashTests/Navigation/YouTabRoutingTests.swift +++ b/FlipcashTests/Navigation/YouTabRoutingTests.swift @@ -99,4 +99,50 @@ struct YouTabRoutingTests { router.popTopmost() #expect(router[.you].isEmpty) } + + // MARK: - Profile card - + + @Test("dismissing a sheet closes the profile card") + func dismissSheet_closesProfileCard() { + let router = AppRouter() + router.present(.give) + router.isShowingProfileCard = true + + router.dismissSheet() + + #expect(!router.isShowingProfileCard) + } + + @Test("changing the selected tab closes the profile card") + func tabChange_closesProfileCard() { + let router = AppRouter() + router.activeTabStack = .you + router.isShowingProfileCard = true + + router.activeTabStack = .balance + + #expect(!router.isShowingProfileCard) + } + + @Test("re-publishing the same tab leaves the profile card up") + func sameTab_keepsProfileCard() { + let router = AppRouter() + router.activeTabStack = .you + router.isShowingProfileCard = true + + router.activeTabStack = .you + + #expect(router.isShowingProfileCard) + } + + @Test("a deep-link tab request closes the profile card") + func tabRequest_closesProfileCard() { + let router = AppRouter() + router.activeTabStack = .you + router.isShowingProfileCard = true + + router.requestedTabStack = .balance + + #expect(!router.isShowingProfileCard) + } } diff --git a/FlipcashTests/ProfileAvatarStoreTests.swift b/FlipcashTests/ProfileAvatarStoreTests.swift index 2c91ce136..e865f0b0e 100644 --- a/FlipcashTests/ProfileAvatarStoreTests.swift +++ b/FlipcashTests/ProfileAvatarStoreTests.swift @@ -12,6 +12,8 @@ import FlipcashCore @Suite("ProfileAvatarStore") struct ProfileAvatarStoreTests { + private typealias AvatarSubject = ProfileAvatarStore.AvatarSubject + /// A suspension the test releases by hand, so a fetch can be held mid-flight while the callers /// around it are cancelled. Not cancellation-aware: the fetch under test checks cancellation /// itself, the way `URLSession` does. @@ -123,6 +125,70 @@ struct ProfileAvatarStoreTests { #expect(fetches.value == 1) } + /// The banner shows the original, not the avatar-sized rendition the same picture carries. + @Test("A cover loads the original blob, independently of the avatar") + func coverLoadsOriginalAndCachesApartFromAvatar() async { + let userID = UUID() + let picture = ProfilePicture( + blobID: BlobID(uuid: UUID()), + thumbnailBlobID: BlobID(uuid: UUID()) + ) + let minted = Counter() + var mintedBlobs: [BlobID] = [] + var mintedContexts: [BlobAccessContext] = [] + + let store = ProfileAvatarStore( + cache: Self.cache(), + coverCache: Self.cache(), + mintURL: { blobID, subject in + mintedBlobs.append(blobID) + mintedContexts.append(subject.accessContext) + minted.increment() + return URL(string: "https://example.test/\(blobID)")! + }, + fetch: { _ in Self.bytes } + ) + + await store.load(.cover(userID), picture: picture) + + #expect(mintedBlobs == [picture.blobID]) + #expect(mintedContexts == [.userProfile(userID)]) + #expect(store.data(for: AvatarSubject.cover(userID)) == Self.bytes) + #expect(store.data(for: userID) == nil) + + await store.load(.user(userID), picture: picture) + + #expect(mintedBlobs == [picture.blobID, picture.thumbnailBlobID]) + #expect(store.data(for: userID) == Self.bytes) + #expect(minted.value == 2) + } + + /// A full-size cover in the avatar pool would evict thumbnails, which the pools are split to prevent. + @Test("A cover is written to the cover pool, not the avatar pool") + func coverUsesItsOwnPool() async { + let userID = UUID() + let picture = ProfilePicture(blobID: BlobID(uuid: UUID()), thumbnailBlobID: BlobID(uuid: UUID())) + let avatarCache = Self.cache() + let coverCache = Self.cache() + + let store = ProfileAvatarStore( + cache: avatarCache, + coverCache: coverCache, + mintURL: { _, _ in URL(string: "https://example.test/cover")! }, + fetch: { _ in Self.bytes } + ) + + await store.load(.cover(userID), picture: picture) + + #expect(coverCache.holds(picture.blobID)) + #expect(!avatarCache.holds(picture.blobID)) + + await store.load(.user(userID), picture: picture) + + #expect(avatarCache.holds(picture.thumbnailBlobID)) + #expect(!coverCache.holds(picture.thumbnailBlobID)) + } + /// Bytes already on disk are the common case at launch, and paying for a round trip there would /// put a blurhash on screen for the length of one. @Test("Bytes already on disk need no fetch") diff --git a/FlipcashTests/ProfileCoverFillTests.swift b/FlipcashTests/ProfileCoverFillTests.swift new file mode 100644 index 000000000..d9f416401 --- /dev/null +++ b/FlipcashTests/ProfileCoverFillTests.swift @@ -0,0 +1,34 @@ +// +// ProfileCoverFillTests.swift +// FlipcashTests +// + +import SwiftUI +import Testing +import FlipcashCore +import FlipcashUI +@testable import Flipcash + +@MainActor +@Suite("ProfileCoverFill") +struct ProfileCoverFillTests { + + @Test("A valid hex gives the parsed colour") + func validHexIsParsed() { + let color = ProfileCoverFill.color(for: TipCardCustomization(colorHex: "#19191A")) + + #expect(color == Color(hex: "#19191A")) + } + + @Test("An unparsable hex gives the default") + func invalidHexFallsBack() { + let color = ProfileCoverFill.color(for: TipCardCustomization(colorHex: "not-a-colour")) + + #expect(color == ProfileCoverFill.fallback) + } + + @Test("No customization gives the default") + func nilFallsBack() { + #expect(ProfileCoverFill.color(for: nil) == ProfileCoverFill.fallback) + } +} diff --git a/FlipcashTests/StartChattingFeeTests.swift b/FlipcashTests/StartChattingFeeTests.swift new file mode 100644 index 000000000..e84af8b7c --- /dev/null +++ b/FlipcashTests/StartChattingFeeTests.swift @@ -0,0 +1,79 @@ +// +// StartChattingFeeTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("StartChattingFee") +struct StartChattingFeeTests { + + private let presets = UserFlags.TipPresets( + currency: .usd, + minimum: Decimal(string: "0.25")!, + low: 5, + medium: 10, + high: 20 + ) + + private let rates: [CurrencyCode: Rate] = [ + .usd: Rate(fx: 1, currency: .usd), + .cad: Rate(fx: Decimal(string: "1.37")!, currency: .cad), + ] + + /// 0.33 USD is 0.4521 CAD: half-up would show 0.45, which sits under the fee. + @Test("A fee shown in another currency rounds up, as the amount screen enforces") + func feeInOtherCurrencyRoundsUp() { + let fee = FiatAmount(value: Decimal(string: "0.33")!, currency: .usd) + + let amount = StartChattingFee.amount( + recipientFee: fee, + presets: presets, + currency: .cad, + rates: rates + ) + + #expect(amount == FiatAmount(value: Decimal(string: "0.46")!, currency: .cad)) + #expect(amount == TipFloor.toOpenDM(recipientFee: fee, presets: presets, in: .cad, rates: rates)?.displayed) + } + + @Test("No fee falls back to the preset minimum") + func nilFeeFallsBackToPresetMinimum() { + let amount = StartChattingFee.amount(recipientFee: nil, presets: presets, currency: .usd, rates: rates) + + #expect(amount == FiatAmount(value: Decimal(string: "0.25")!, currency: .usd)) + } + + @Test("No fee and no presets gives nothing") + func nilFeeAndNilPresetsIsNil() { + let amount = StartChattingFee.amount(recipientFee: nil, presets: nil, currency: .usd, rates: rates) + + #expect(amount == nil) + } + + /// The session overload only supplies the user's balance currency and cached rates. + @Test("The profile's fee is read in the rates controller's balance currency") + func profileFeeUsesBalanceCurrency() { + let ratesController = RatesController(container: .mock, database: .mock) + let currency = ratesController.balanceCurrency + // In the balance currency already, so the result does not depend on loaded rates. + let fee = FiatAmount(value: Decimal(string: "0.33")!, currency: currency) + let profile = Profile(displayName: "Ted", phone: Optional.none, email: nil, minDmChatInitFee: fee) + + let amount = StartChattingFee.amount(for: profile, session: .mock, ratesController: ratesController) + + let expected = StartChattingFee.amount( + recipientFee: fee, + presets: nil, + currency: currency, + rates: ratesController.cachedRates + ) + #expect(amount != nil) + #expect(amount == expected) + #expect(amount?.currency == currency) + } +} diff --git a/FlipcashTests/TipCardExportTests.swift b/FlipcashTests/TipCardExportTests.swift index 26a8c197a..fa419c618 100644 --- a/FlipcashTests/TipCardExportTests.swift +++ b/FlipcashTests/TipCardExportTests.swift @@ -18,7 +18,7 @@ struct TipCardExportTests { let url = try #require(TipCardExport.file(for: .svg, codeData: .placeholder35, name: "Ada")) defer { TipCardExport.discard(url) } - #expect(url.lastPathComponent == "Tip Ada.svg") + #expect(url.lastPathComponent == "Chat with Ada.svg") let svg = try String(contentsOf: url, encoding: .utf8) #expect(svg.hasPrefix("