diff --git a/.claude/plans/2026-10-06-featured-groups.md b/.claude/plans/2026-10-06-featured-groups.md new file mode 100644 index 000000000..18ac8b14f --- /dev/null +++ b/.claude/plans/2026-10-06-featured-groups.md @@ -0,0 +1,26 @@ +# Favorite public groups on profiles (flipcash2 0.18.0) + +Contract + wrappers already on main (257a089d, 721fb442): `FlipClient.getFeaturedGroups(owner:username:)`, +`setFeaturedGroups(owner:conversationIDs:)`. This plan is the app layer and UI only. + +Branch `feat/profile-featured-groups`, cut from `feat/profile-refresh-copy` (#990) with `origin/main` merged in +(the stack predates the 0.18.0 wrappers). + +## Proto facts that shape the design +- Ordered list, max 10 (PGV `max_items`, so exceeding it is a transport invalid-argument — cap client-side). +- Written whole; empty clears. Only public groups (`.denied` otherwise, names no group). +- Featuring is independent of membership: leaving a group does not unfeature it. +- `GetFeaturedGroups` returns list-view `Metadata` (no members, viewer state, last message, cover). + +## Shape +- `FeaturedGroups` (`@MainActor @Observable`, `let` on `SessionContainer`): the signed-in user's list, + shared by the You tab section, the Edit Profile row ("N selected") and the picker. Closures injected + (fetch/save) like `EditBioModel`. Never seated into `ConversationStore`. +- `UserProfileViewModel.featuredGroups`: fetched after the profile (needs `username`). +- `FeaturedGroupsSection` view: "Favorite Public Groups" header + rows (group avatar, title, + description ?? people count); tap → `router.push(.tipConversation(id))`. Hidden when empty. +- Edit Profile: `FieldCard` "Favorite Public Groups" → new `.editFeaturedGroups` destination. +- Picker `EditFeaturedGroupsScreen` + `EditFeaturedGroupsModel`: candidates = current featured (server + order) ∪ joined public groups from `loadGroupFeed()`'s result (`isPrivate` isn't persisted, so the + store's copy can't be trusted offline; fallback = store joined groups with `!isPrivate && !useE2Ee`). + Search by title, ordered selection, cap 10, bottom filled Save like `EditBioScreen`. 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/BetaFlags.swift b/Flipcash/Core/Controllers/BetaFlags.swift index f8db65fef..425256fc2 100644 --- a/Flipcash/Core/Controllers/BetaFlags.swift +++ b/Flipcash/Core/Controllers/BetaFlags.swift @@ -67,6 +67,9 @@ class BetaFlags { /// You-tab long press share this gate, so the two never disagree. var canSwitchAccounts: Bool { accessGranted } + /// Whether Settings shows the Account Info row. + var canViewAccountInfo: Bool { accessGranted } + /// Enables or disables a beta flag and persists the change to disk. func set(_ option: Option, enabled: Bool) { if enabled { diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 366400250..312fb1a3e 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1691,6 +1691,12 @@ final class ConversationController { } } + /// The ID of the tip DM with `userID`, or nil when the chat hasn't started. The single "chat + /// started" rule: the visible DM. Hidden chats are excluded, and a block takes precedence anyway. + func tipDMID(withUserID userID: UserID) -> ConversationID? { + tipDM(withUserID: userID)?.id + } + private func contactName(for conversationID: ConversationID) -> String? { guard let name = contactNaming.contactDisplayName(forDMChat: conversationID), !name.isEmpty else { diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index d28088ec5..089cee138 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, @@ -326,7 +326,7 @@ struct DeepLinkAction { private static func showLookupFailed(isScan: Bool, on session: Session) { session.dialogItem = .error( - title: isScan ? "Couldn't Open Tip Card" : "Couldn't Open Profile", + title: isScan ? "Couldn't Open Profile Card" : "Couldn't Open Profile", subtitle: "Please check your connection and try again" ) } @@ -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+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ffc12148c..fc67e67c0 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -56,8 +56,19 @@ extension AppRouter { case usdcDepositAddress // Settings flow - case settingsMyAccount - /// The display name on its own, edited from My Account. The full + /// The Settings list, opened from the gear on the You tab. + case settings + /// The account's phone, email, user ID and public key, opened from Settings. + case accountInfo + /// The signed-in user's profile fields in one list, opened from the You tab's Edit Profile button. + case editProfile + /// The bio on its own, pushed from Edit Profile. + case editBio + /// The public groups shown on the signed-in user's profile, chosen from Edit Profile. + case editFeaturedGroups + /// The cover banner on its own, pushed from Edit Profile. + case changeCoverPicture + /// The display name on its own, edited from Settings. The full /// profile-setup flow starts on the same screen but carries on to the /// tip card; this one returns to the settings list. case changeDisplayName @@ -65,15 +76,14 @@ extension AppRouter { /// The full profile-setup flow uses `.profilePhoto`, which carries on to /// the tip card; this one returns to the screen that opened it. case changeProfilePicture - /// The public handle, claimed from the You page or changed from My - /// Account. One destination for both: the screen seeds itself from the + /// The public handle, claimed from the You page or changed from + /// Settings. One destination for both: the screen seeds itself from the /// handle already on the profile, so there is nothing to distinguish. case username(Username?) /// The minimum a tipper must pay to open a DM. `isSetupStep` marks the - /// You tab's checklist apart from a lone edit in My Account; it reaches + /// You tab's checklist apart from a lone edit in Settings; it reaches /// the payload only, since the screen is the same from either. case setMinimumTip(isSetupStep: Bool) - case settingsAdvancedFeatures case settingsAdvancedBetaFeatures case settingsAppSettings case settingsAccountSelection @@ -103,14 +113,6 @@ extension AppRouter { /// trace shows "post-tip, keyboard up" distinctly, and so the ordinary /// tip-list / push-notification opens stay keyboard-closed untouched. case tipConversationWithKeyboard(ConversationID) - /// A tip DM named by its counterpart rather than its chat id — the - /// username lookup's destination. The chat is created by the first tip, - /// so before then there is no id to push; the screen derives one. - case tipConversationForUser(UserID) - /// Same chat as `tipConversationForUser`, opened with Send Cash already started — the - /// profile's Send Cash button. A sibling case for the same reason as - /// `tipConversationWithKeyboard`: the trace shows the send-first open distinctly. - case tipConversationForUserSendingCash(UserID) /// A person's Flipcash profile, pushed from a tip DM's title/card or a group member's face; /// hosts the Block action. `origin` decides whether it offers a way into the DM. case userProfile(UserID, origin: UserProfileOrigin) @@ -141,14 +143,14 @@ extension AppRouter { .buyCurrency, .convertCurrency, .withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress: return .balance - case .settingsMyAccount, .changeDisplayName, .changeProfilePicture, .username, - .setMinimumTip, .settingsAdvancedFeatures, + case .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture, + .changeDisplayName, .changeProfilePicture, .username, + .setMinimumTip, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw: return .you case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, - .tipConversation, .tipConversationWithKeyboard, .tipConversationForUser, - .tipConversationForUserSendingCash, .userProfile, .chatProfile, + .tipConversation, .tipConversationWithKeyboard, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .archivedChats: return .tips } @@ -176,12 +178,16 @@ extension AppRouter { case .withdrawCurrency: "withdrawCurrency" case .usdcDepositEducation: "usdcDepositEducation" case .usdcDepositAddress: "usdcDepositAddress" - case .settingsMyAccount: "settingsMyAccount" + case .settings: "settings" + case .accountInfo: "accountInfo" + case .editProfile: "editProfile" + case .editBio: "editBio" + case .editFeaturedGroups: "editFeaturedGroups" + case .changeCoverPicture: "changeCoverPicture" case .changeDisplayName: "changeDisplayName" case .changeProfilePicture: "changeProfilePicture" case .username: "username" case .setMinimumTip: "setMinimumTip" - case .settingsAdvancedFeatures: "settingsAdvancedFeatures" case .settingsAdvancedBetaFeatures: "settingsAdvancedBetaFeatures" case .settingsAppSettings: "settingsAppSettings" case .settingsAccountSelection: "settingsAccountSelection" @@ -197,8 +203,6 @@ extension AppRouter { case .newPublicGroup: "newPublicGroup" case .tipConversation: "tipConversation" case .tipConversationWithKeyboard: "tipConversationWithKeyboard" - case .tipConversationForUser: "tipConversationForUser" - case .tipConversationForUserSendingCash: "tipConversationForUserSendingCash" case .userProfile: "userProfile" case .chatProfile: "chatProfile" case .editGroup: "editGroup" @@ -232,9 +236,7 @@ extension AppRouter { .editGroupName(let conversationID), .editGroupPicture(let conversationID): return conversationID.description - case .userProfile(let userID, _), - .tipConversationForUser(let userID), - .tipConversationForUserSendingCash(let userID): + case .userProfile(let userID, _): return userID.uuidString case .username(let username): return username?.value @@ -243,8 +245,8 @@ extension AppRouter { case .activity, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .usdcDepositEducation, .usdcDepositAddress, - .settingsMyAccount, .changeDisplayName, .changeProfilePicture, - .settingsAdvancedFeatures, + .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture, + .changeDisplayName, .changeProfilePicture, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw, .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index f5068a2ce..b7328c9b7 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -82,8 +82,43 @@ struct DestinationView: View { // MARK: - Settings flow - case .settingsMyAccount: - SettingsMyAccountScreen() + case .settings: + SettingsScreen() + case .accountInfo: + AccountInfoScreen() + + case .editProfile: + EditProfileScreen() + + case .editBio: + // Seeded here so the field opens on the bio it is about to replace. + let session = sessionContainer.session + let flipClient = container.flipClient + EditBioScreen(model: EditBioModel( + bio: session.profile?.bio ?? "", + saving: { try await flipClient.setBio($0, owner: session.ownerKeyPair) }, + refresh: { try await session.updateProfile() } + )) + + case .editFeaturedGroups: + let session = sessionContainer.session + let flipClient = container.flipClient + let featuredGroups = sessionContainer.featuredGroups + let conversationController = sessionContainer.conversationController + EditFeaturedGroupsScreen(model: EditFeaturedGroupsModel( + featured: featuredGroups.groups, + loadingFeatured: { + // The list is read by handle; with none there is nothing to read back. + guard let username = session.profile?.username else { return [] } + return await featuredGroups.load(username: username) ? featuredGroups.groups : nil + }, + joinedGroups: { await conversationController.loadGroupFeed() }, + saving: { try await flipClient.setFeaturedGroups(owner: session.ownerKeyPair, conversationIDs: $0) }, + saved: { featuredGroups.replace(with: $0) } + )) + + case .changeCoverPicture: + ChangeCoverPictureScreen() case .changeDisplayName: ChangeDisplayNameScreen(currentName: sessionContainer.session.profile?.displayName ?? "") @@ -106,8 +141,6 @@ struct DestinationView: View { ) ) - case .settingsAdvancedFeatures: - SettingsAdvancedFeaturesScreen() case .settingsAdvancedBetaFeatures: SettingsAdvancedBetaFeaturesScreen() @@ -194,17 +227,6 @@ struct DestinationView: View { ConversationScreen(context: .existing(conversationID), openKeyboard: true) .id(conversationID) - case .tipConversationForUser(let userID): - // Opened before the chat exists, so the screen is given the person - // and derives the chat id itself. `.id` forces fresh view identity. - ConversationScreen(context: .tipDM(counterpart: userID)) - .id(userID) - - case .tipConversationForUserSendingCash(let userID): - // The profile's Send Cash: the same chat, with the send started on open. - ConversationScreen(context: .tipDM(counterpart: userID), startSendCash: true) - .id(userID) - case .userProfile(let userID, let origin): UserProfileScreen(userID: userID, origin: origin) .id(userID) diff --git a/Flipcash/Core/Navigation/AppRouter.swift b/Flipcash/Core/Navigation/AppRouter.swift index 324483a24..7fd7ac122 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 @@ -254,6 +267,19 @@ final class AppRouter { return StackPosition(stack: stack, depth: max(self[stack].count - 1, 0)) } + /// The topmost stack and how many destinations it holds — the position of the screen on top of it. + /// `nil` when there is no topmost stack. + func positionOfTopmost() -> StackPosition? { + guard let stack = topmostStack else { return nil } + return StackPosition(stack: stack, depth: self[stack].count) + } + + /// Whether `position` is still the topmost stack's top, so nothing has been pushed over it and no + /// other tab or sheet is in front. + func isTopmost(_ position: StackPosition) -> Bool { + positionOfTopmost() == position + } + /// Pops `position.stack` back down to `position.depth`, leaving whatever sat beneath intact. func popTo(_ position: StackPosition) { popLast(self[position.stack].count - position.depth, on: position.stack) @@ -458,6 +484,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 +604,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/Navigation/DMRoute.swift b/Flipcash/Core/Navigation/DMRoute.swift new file mode 100644 index 000000000..7b8d8bd81 --- /dev/null +++ b/Flipcash/Core/Navigation/DMRoute.swift @@ -0,0 +1,22 @@ +// +// DMRoute.swift +// Flipcash +// + +import FlipcashCore + +/// Where opening a person lands: their DM once it exists, their profile until then. +nonisolated enum DMRoute { + + /// The destination for opening `userID`, given the DM with them if there is one. + static func destination( + for userID: UserID, + dmID: ConversationID?, + origin: UserProfileOrigin + ) -> AppRouter.Destination { + if let dmID { + return .tipConversation(dmID) + } + return .userProfile(userID, origin: origin) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index d040c30c8..87158330f 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -80,7 +80,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Fired when the long-press strip's "+" is tapped, with the row's stable id. let onReactionStripAdd: (String) -> Void let showsSendCash: Bool - let chatExists: Bool let conversationID: ConversationID? let symbol: String let onSendCash: () -> Void @@ -95,10 +94,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// the composer field in `viewDidAppear` — a hosted SwiftUI `@FocusState` never presents the /// keyboard across the hosting boundary. let focusOnAppear: Bool - /// Whether this is a tip DM, whose Send Cash reads Start Chatting until the chat exists. - let isTipDm: Bool - /// The floor the first tip has to clear to open this chat, named on the CTA. - let startChattingFee: FiatAmount? /// Whether the chat's participation rules leave this user anything to type, and whether they /// may read at all. Drives the gate panel in place of the bar and the blur over the transcript. let gate: ConversationGatePresentation @@ -150,7 +145,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.focusesComposerOnAppear = focusOnAppear screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder - screen.barRestingDrop = chatExists ? BarMetrics.compactDrop : 0 + screen.barRestingDrop = BarMetrics.compactDrop screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry @@ -206,7 +201,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { context.coordinator.barHost?.rootView = bar(coordinator: context.coordinator) screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder - screen.barRestingDrop = chatExists ? BarMetrics.compactDrop : 0 + screen.barRestingDrop = BarMetrics.compactDrop screen.authorAvatars = authorAvatars screen.onReachTop = onReachTop screen.onRetry = onRetry @@ -313,14 +308,11 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { return AnyView( ConversationBottomBar( showsSendCash: showsSendCash, - chatExists: chatExists, conversationID: conversationID, symbol: symbol, onSendCash: onSendCash, model: barModel, composer: composer, - isTipDm: isTipDm, - startChattingFee: startChattingFee, gate: gate, gateMintName: gateMintName, onGateAddFunds: onGateAddFunds, diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 88e09ed97..2799f600b 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -140,8 +140,7 @@ struct BarOverflowReporting: ViewModifier { } } -/// Single spring driving the whole bar: the button morph, the composer's -/// appearance when the chat materializes, and the send-arrow pop. +/// Single spring driving the bar's morphs: the focus-driven resize and the send-arrow pop. private let barMorphSpring = ChatMotion.swap.animation /// The curve the bar grows and shrinks on around the reply strip. @@ -182,22 +181,20 @@ enum BarMetrics { enum ConversationBarLeadingControl: Equatable { /// The way out of an edit. case cancelEdit - /// The full-width Send Cash call to action, alone in the bar before the chat exists. - case sendCash - /// The round `$` beside the field once the chat exists, shown while the draft is empty. + /// The round `$` beside the field, shown while the draft is empty. case cash /// Nothing beside the field. case none - /// Returns the control for the bar's state. An edit wins; otherwise Send Cash, as the call to - /// action before the chat exists and as the round `$` after. - init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool) { + /// Returns the control for the bar's state. An edit wins; otherwise the round `$` when Send Cash + /// is offered. + init(isEditing: Bool, showsSendCash: Bool) { if isEditing { self = .cancelEdit } else if !showsSendCash { self = .none } else { - self = chatExists ? .cash : .sendCash + self = .cash } } } @@ -214,10 +211,10 @@ struct ConversationBarBottomRow: Equatable { /// Whether there is nothing for `+` to open. var isEmpty: Bool { plusItems.isEmpty } - /// Returns the row for the bar's state: empty during an edit and before the chat exists; - /// otherwise `+` while it has a row to open. - init(isEditing: Bool, chatExists: Bool, acceptsMedia: Bool, attachedCount: Int) { - guard !isEditing, chatExists else { + /// Returns the row for the bar's state: empty during an edit; otherwise `+` while it has a row + /// to open. + init(isEditing: Bool, acceptsMedia: Bool, attachedCount: Int) { + guard !isEditing else { self.init(plusItems: []) return } @@ -230,21 +227,14 @@ struct ConversationBarBottomRow: Equatable { } /// The unified bottom bar: the attach menu beside the message field. -/// A standard-size filled Send Cash alone until the chat exists server-side. struct ConversationBottomBar: View { let showsSendCash: Bool - let chatExists: Bool let conversationID: ConversationID? let symbol: String let onSendCash: () -> Void let model: ConversationBarModel let composer: ComposerModel - /// Whether this is a tip DM, whose Send Cash reads Start Chatting until the chat exists. - var isTipDm: Bool = false - /// What the first tip has to clear to open this chat, named on the CTA. - /// Nil once the chat exists, and when no floor has resolved yet. - var startChattingFee: FiatAmount? = nil /// Whether the chat's participation rules leave this user anything to type. Anything but /// ``ConversationGatePresentation/open`` replaces the whole composer with the gate panel. var gate: ConversationGatePresentation = .open @@ -279,7 +269,7 @@ struct ConversationBottomBar: View { switch leadingControl { case .cash: cashIsShown ? BarMetrics.contentHeight + Self.leadingSpacing : 0 - case .cancelEdit, .sendCash, .none: + case .cancelEdit, .none: 0 } } @@ -314,9 +304,8 @@ struct ConversationBottomBar: View { static let leadingSpacing: CGFloat = 10 /// Whether the bar sits inset from the screen's sides: at rest with the keyboard down. It widens - /// to the full edge inset as the keyboard comes up. Only once there is a composer; the pre-chat - /// CTA keeps its full-width button. - private var isCompact: Bool { chatExists && !model.isComposing } + /// to the full edge inset as the keyboard comes up. + private var isCompact: Bool { !model.isComposing } /// How much further in than ``BarMetrics/edgeInset`` the controls and the reply quote sit. private var compactExtraInset: CGFloat { isCompact ? BarMetrics.compactInset - BarMetrics.edgeInset : 0 } @@ -364,7 +353,7 @@ struct ConversationBottomBar: View { .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) .composerGlass(in: Circle(), id: "cash", namespace: composerGlassNamespace) } - case .sendCash, .none: + case .none: EmptyView() } Color.clear @@ -381,8 +370,8 @@ struct ConversationBottomBar: View { private var composerBar: some View { // Bottom-aligned, against the bar's own pinned bottom: the field is the side that grows, and // top-aligning the control beside it made the control travel with every line the draft - // gained or lost. Nothing animates that travel — the bar's springs key on `chatExists` and - // `isEditing`, neither of which moves during a send — so it snapped while the bar's height + // gained or lost. Nothing animates that travel — the bar's springs key on `isEditing`, + // which doesn't move during a send — so it snapped while the bar's height // sprang underneath it. let motion = AttachMotion(reduceMotion: reduceMotion) let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { @@ -392,22 +381,6 @@ struct ConversationBottomBar: View { switch leadingControl { case .cancelEdit: CancelEditButton { composer.endEditing() } - case .sendCash: - SendCashMorphButton( - symbol: symbol, - // Minimized by a reply as well as by focus. Starting a reply from the context - // menu raises the keyboard, and focus — and so `isComposing` — arrives a - // transaction later than the reply target, on its own bouncy spring: the button - // collapsed after the bar had already grown, jolting the field beside it. - // Reading the target directly puts the morph in the reply's own transaction, so - // the two move together and the later focus change finds nothing left to do. - composing: model.isComposing || composer.replyTarget != nil, - // Before the first tip there is no composer to sit beside: the design draws - // the full-width "Start Chatting" CTA (node 10074:18891). - standalone: true, - expandedTitle: isTipDm ? startChattingTitle : nil, - action: onSendCash - ) case .cash: if cashIsShown { ComposerCashButton(symbol: symbol, action: onSendCash) @@ -416,26 +389,21 @@ struct ConversationBottomBar: View { case .none: EmptyView() } - if chatExists { - ConversationComposer( - conversationID: conversationID, - model: model, - composer: composer, - bottomRow: bottomRow, - hidesPlus: showsCard, - // Swapped out in one frame for the surface, which is drawn as `+` where it stands. - plusIsStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, - onAttachOpen: onAttachOpen - ) - // Over the row's other controls, which the attach panel floats across. - .zIndex(1) - .transition(.opacity) - } + ConversationComposer( + conversationID: conversationID, + model: model, + composer: composer, + bottomRow: bottomRow, + hidesPlus: showsCard, + // Swapped out in one frame for the surface, which is drawn as `+` where it stands. + plusIsStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, + onAttachOpen: onAttachOpen + ) + // Over the row's other controls, which the attach panel floats across. + .zIndex(1) } .background { - if chatExists { - composerGlass - } + composerGlass } // A draft already there when the chat opens hides `$` without animating it out. .onAppear { cashIsShown = composer.draft.isEmpty } @@ -468,7 +436,6 @@ struct ConversationBottomBar: View { .animation(Self.widthSpring, value: isCompact) .padding(.top, BarMetrics.contentPadding) .padding(.bottom, BarMetrics.contentPadding) - .animation(barMorphSpring, value: chatExists) .animation(barMorphSpring, value: composer.isEditing) // The strip arriving with its first chip and leaving with its last, on the chip spring unless // a capture's animation is already carrying it. @@ -652,7 +619,6 @@ struct ConversationBottomBar: View { private var leadingControl: ConversationBarLeadingControl { ConversationBarLeadingControl( isEditing: composer.isEditing, - chatExists: chatExists, showsSendCash: showsSendCash ) } @@ -660,19 +626,10 @@ struct ConversationBottomBar: View { private var bottomRow: ConversationBarBottomRow { ConversationBarBottomRow( isEditing: composer.isEditing, - chatExists: chatExists, acceptsMedia: acceptsMedia, attachedCount: composer.chips.count ) } - - /// The tip CTA's title. Names the amount that opens the chat when a floor - /// has resolved; the fee is what the recipient charges for the - /// conversation, so stating it is the whole point of the button. - private var startChattingTitle: String { - guard let startChattingFee else { return "Start Chatting" } - return "Send \(startChattingFee.formatted()) to Start Chatting" - } } /// A card's arrival and departure above the composer row — the reply strip or the mention list: @@ -1394,112 +1351,3 @@ private struct CancelEditButton: View { .accessibilityIdentifier("cancel-edit-button") } } - -/// The Send Cash button, rendered as a white "Send €" pill at rest and a -/// compact glass "€" square while composing (or always, in a tip chat). Alone -/// in the bar it takes the standard filled-button size; beside the composer -/// it's field-sized. -// One persistent view: the morph animates its properties (prefix text, fill, -// width, color) in lockstep — splitting the two states into separate views -// would crossfade instead of morphing. -struct SendCashMorphButton: View { - - let symbol: String - let composing: Bool - /// Whether the button is the bar's only control (no chat yet): it spans - /// the bar at the standard filled-button size instead of field-sized. - let standalone: Bool - /// Forces the compact symbol-only presentation regardless of composing. - var alwaysMinimized: Bool = false - /// Replaces "Send " while expanded. A tip chat names the tip - /// instead of the currency, because the amount is chosen on the next screen. - var expandedTitle: String? - let action: () -> Void - - /// The compact glass "€" presentation: while composing, or always once a - /// chat exists. The whole morph (label, fill, width, color) keys off this. - private var minimized: Bool { composing || alwaysMinimized } - - private var height: CGFloat { - standalone ? Metrics.buttonHeight : BarMetrics.contentHeight - } - - private var cornerRadius: CGFloat { - standalone ? Metrics.buttonRadius : BarMetrics.cornerRadius - } - - var body: some View { - Button(action: action) { - HStack(spacing: 4) { - if !minimized { - Text(expandedTitle ?? "Send") - .font(.appTextMedium) - .transition(.opacity) - } - // Suppressed while a custom title is showing: "Send a Tip $" - // isn't a label. It returns when the button minimizes. - if minimized || expandedTitle == nil { - Text(symbol) - // Same persistent Text — .interpolate animates the glyph - // between sizes; swapping views would crossfade. - .font(minimized ? .appTextXL : .appTextMedium) - .contentTransition(.interpolate) - } - } - .foregroundStyle(minimized ? Color.textMain : Color.textAction) - // The label must never reflow to "Se…" mid-morph; overflow is - // clipped by the shape instead. - .fixedSize() - .padding(.horizontal, minimized ? 0 : 20) - .frame(minWidth: BarMetrics.contentHeight) - .frame(maxWidth: standalone && !minimized ? .infinity : nil) - .frame(height: height) - // The label is the only drawn content and the fill is a background on the button, not - // on the label, so with `.plain` only the text was the target: alone in the bar the - // pill spans the width but "Send a Tip" answered a tap on its centre and nothing else. - .contentShape(RoundedRectangle(cornerRadius: cornerRadius)) - } - .buttonStyle(.plain) - // White fill above the glass base: fading it out is the white → glass - // change, without ever swapping views. - .background { - RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color.action) - .opacity(minimized ? 0 : 1) - } - .glassBackground(cornerRadius: cornerRadius) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .accessibilityLabel(expandedTitle ?? "Send Cash") - .accessibilityIdentifier("send-cash-button") - } -} - -#Preview("Morph") { - @Previewable @State var composing = false - ZStack { - Color.backgroundMain.ignoresSafeArea() - VStack { - Spacer() - HStack(spacing: 10) { - SendCashMorphButton(symbol: "€", composing: composing, standalone: false) { - withAnimation(barMorphSpring) { composing.toggle() } - } - RoundedRectangle(cornerRadius: BarMetrics.cornerRadius) - .fill(.white.opacity(0.1)) - .frame(height: BarMetrics.contentHeight) - } - .padding(12) - } - } -} - -#Preview("Standalone") { - ZStack { - Color.backgroundMain.ignoresSafeArea() - VStack { - Spacer() - SendCashMorphButton(symbol: "€", composing: false, standalone: true) {} - .padding(12) - } - } -} diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index dcbc59cec..f9967f06d 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -16,15 +16,10 @@ import FlipcashUI // foreground-banner suppression gate on — so a missed buzz or an unsuppressed banner is traceable. private let logger = Logger(label: "flipcash.conversation") -/// How a conversation is reached: an existing DM chat (only tip DMs are -/// surfaced now — contact/phone DMs were retired with the Send tab), or a tip -/// DM named by its counterpart before the chat exists server-side. +/// How a conversation is reached: an existing chat, by its id. A tip DM that doesn't exist yet +/// is reached through the counterpart's profile, not here. nonisolated enum ConversationContext: Hashable { case existing(ConversationID) - /// A tip DM opened from the username lookup. The chat is created by the - /// first tip, so until then there is no record to reach it by — only the - /// counterpart, whose id derives the chat's own id locally. - case tipDM(counterpart: UserID) /// Resolves the counterpart's synced contact from the directory — the one /// rule the nav title, transcript profile card, and profile page share. @@ -32,19 +27,13 @@ nonisolated enum ConversationContext: Hashable { switch self { case .existing(let conversationID): directory.first { $0.dmChatID == conversationID.data } - case .tipDM: - // A tip DM's counterpart is known by profile, never by address book. - nil } } } /// A DM conversation: an iMessage-style transcript over a unified bottom bar /// (Send Cash beside the composer). Reads live messages from -/// `ConversationController`, which owns the single event stream. For a -/// contact without a chat the transcript stays empty and only Send Cash -/// shows; once the first payment creates the chat, the chat ID resolves live -/// from the synced directory and the composer appears. +/// `ConversationController`, which owns the single event stream. struct ConversationScreen: View { let context: ConversationContext @@ -53,10 +42,6 @@ struct ConversationScreen: View { /// post-tip navigation; every other entry point opens keyboard-closed. var openKeyboard: Bool = false - /// Start Send Cash once on open, as though its button had been tapped. Set only by the - /// profile's Send Cash; every other entry point waits for the tap. - var startSendCash: Bool = false - @Environment(ConversationController.self) private var conversationController @Environment(ContactSyncController.self) private var contactSyncController @Environment(AppRouter.self) private var router @@ -79,7 +64,6 @@ struct ConversationScreen: View { /// below only corrects it. @State private var navBarWidth: CGFloat = UIApplication.shared.firstWindowScene?.coordinateSpace.bounds.width ?? 0 @State private var presentedCard: ContactCard? - @State private var startChattingRequest: StartChattingRequest? @State private var coordinator: ConversationLoadCoordinator? /// Tickers for the mints the gate's copy names, resolved from mint metadata. Empty until they /// land, and for every ungated chat. @@ -95,9 +79,6 @@ struct ConversationScreen: View { /// Whether the encryption explainer opened from the transcript's marker is showing. @State private var isShowingEncryptionInfo = false @State private var messageReport: MessageReportRequest? - /// Whether `startSendCash` has been acted on, so a re-render or a return from the sheet it - /// opened doesn't start it again. - @State private var didStartSendCash = false /// Whether `Group: Gate Shown` has gone out for this visit. The destination is keyed by /// conversation ID, so a new push is a new visit; a return from a pushed screen is not. @State private var didReportGate = false @@ -131,31 +112,17 @@ struct ConversationScreen: View { switch context { case .existing(let conversationID): return conversationID - case .tipDM(let counterpart): - // The same derivation the server uses, so the id is known before - // the chat is — and matches the one the first tip creates. - return .tipDm(between: conversationController.selfUserID, and: counterpart) - } - } - - /// The counterpart this screen was opened for, when it was opened by - /// person rather than by chat. - private var counterpartUserID: UserID? { - switch context { - case .existing: nil - case .tipDM(let counterpart): counterpart } } - /// The tip DM counterpart, when this conversation is a tip DM. Falls back - /// to the cached profile before the first tip creates the chat. + /// The tip DM counterpart, when this conversation is a tip DM. private var tipCounterpart: ConversationMember? { if let conversationID, let conversation = conversationController.conversation(withID: conversationID), conversation.type == .tipDm { return conversation.counterpart(excluding: conversationController.selfUserID) } - return Self.cachedCounterpart(counterpartUserID, session: session) + return nil } /// Who Send Cash pays: the tip counterpart in a tip DM, the chat itself in @@ -167,53 +134,11 @@ struct ConversationScreen: View { return .contact(contact) } guard let conversationID else { return nil } - if let target = SendTarget( + return SendTarget( conversation: conversationController.conversation(withID: conversationID), dmChatID: conversationID.data, selfUserID: conversationController.selfUserID - ) { - return target - } - // No conversation record to read the counterpart from yet, so the - // cached profile is what the tip is addressed to. - guard let member = tipCounterpart, let userID = member.userID else { return nil } - return .tip(TipRecipient( - userID: userID, - displayName: member.displayName, - username: member.username, - origin: .chat - )) - } - - /// What the first tip has to clear to open this chat — the fee the - /// counterpart charges for the conversation, falling back to the regional - /// tip minimum when they charge nothing. Nil once the chat exists, since a - /// send into an open thread carries no floor. - /// - /// Derived from the same inputs as `SendAmountViewModel.tipFloor(in:)` so - /// 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 - } - - /// Whether a chat exists to hold a transcript. An `existing` conversation - /// was reached by its chat id, so it does by construction; a tip DM opened - /// by counterpart does not until the first tip creates it server-side. - private var chatExists: Bool { - guard let conversationID else { return false } - switch context { - case .existing: - return true - case .tipDM: - return conversationController.conversation(withID: conversationID) != nil - } + ) } /// For a tip DM, all counterpart taps open the profile screen — even when @@ -258,11 +183,6 @@ struct ConversationScreen: View { } private var title: String { - // Without a chat there is no conversation record to name, so the - // counterpart's cached profile is the only source for the title. - if !chatExists, let name = tipCounterpart?.displayName { - return name - } if let conversationID { return conversationController.displayName(forConversationID: conversationID) } @@ -297,12 +217,7 @@ struct ConversationScreen: View { /// is what creates it. private var awaitingMetadata: Bool { guard let conversationID else { return false } - switch context { - case .tipDM: - return false - case .existing: - return conversationController.conversation(withID: conversationID) == nil - } + return conversationController.conversation(withID: conversationID) == nil } /// The rule verdicts behind ``gate``, kept separately because the head card states the chat's @@ -512,7 +427,6 @@ struct ConversationScreen: View { onReactionStripSelect: toggleReaction, onReactionStripAdd: openReactionPicker, showsSendCash: sendTarget != nil, - chatExists: chatExists, conversationID: conversationID, symbol: ratesController.balanceCurrency.compactSymbol, onSendCash: sendCash, @@ -521,8 +435,6 @@ struct ConversationScreen: View { composer: composer, editingStableID: composer.editingStableID, focusOnAppear: openKeyboard, - isTipDm: tipCounterpart != nil, - startChattingFee: startChattingFee, gate: gate, // A viewer the gate refuses is refused the read too, so a blocked chat they have no // history of has nothing under its blur. The shapes stand in for what they are not @@ -531,7 +443,7 @@ struct ConversationScreen: View { // whose rules haven't landed yet is blurred without yet refusing anything. // Also stands in while an empty transcript's first load is out, so a chat with nothing // cached opens on the placeholder and paints once with its history. - showsGatePlaceholder: (gate.withholdsTranscript || (chatExists && !didInitialRead)) && (coordinator?.items.isEmpty ?? true), + showsGatePlaceholder: (gate.withholdsTranscript || !didInitialRead) && (coordinator?.items.isEmpty ?? true), gateMintName: gateMintName, onGateAddFunds: addFunds, onGateJoin: joinChat, @@ -623,7 +535,7 @@ struct ConversationScreen: View { // `onReachTop` on every scroll frame it spends near the top, and reading `gate` re-evaluates // the chat's rules against the balance and the rate table each time. let gate = self.gate - let pagesHistory = chatExists && !gate.obscuresTranscript + let pagesHistory = !gate.obscuresTranscript // Stages, rather than one chain. A getter is a single type-check budget however many // statements it holds, and this chain is more than the compiler will finish inside one — // it gives up on CI, where the budget is tighter than on a dev machine. A function each @@ -818,9 +730,6 @@ struct ConversationScreen: View { ContactCardView(card: card) .ignoresSafeArea() } - .sheet(item: $startChattingRequest) { request in - StartChattingSheet(target: request.target, fee: request.fee) - } .sheet(isPresented: $isShowingEncryptionInfo) { E2eeLearnMoreSheet(kind: .dm, isPresented: $isShowingEncryptionInfo) } @@ -867,8 +776,8 @@ struct ConversationScreen: View { /// Takes `gate` rather than reading it, so these closures capture the value `body` resolved. private func lifecycle(_ content: some View, gate: ConversationGatePresentation) -> some View { content - .task(id: chatExists ? conversationID : nil) { - guard chatExists, let conversationID else { return } + .task(id: conversationID) { + guard let conversationID else { return } // Ensure the conversation metadata is in the store before the title, tip styling, and Send // Cash target rely on it. The post-tip open (and any push/link that lands here before the // feed or stream has the freshly-created chat) would otherwise render the unresolved @@ -888,7 +797,7 @@ struct ConversationScreen: View { // The opening task read the gate it started with, which for a chat reached by link or // push is `.undetermined`: a non-member the rules admit, or a balance that crosses the // requirement mid-screen, lifts the blur without re-running it. A join loads for itself. - guard !obscures, !didInitialRead, !isJoiningChat, chatExists, let conversationID else { return } + guard !obscures, !didInitialRead, !isJoiningChat, let conversationID else { return } Task { await loadTranscript(for: conversationID) } } // Buzz on a live message from the other side while this conversation is on screen. `old != nil` @@ -914,11 +823,6 @@ struct ConversationScreen: View { syncCoordinator(id) restoreDraft(id) } - .onChange(of: isReadyToStartSendCash, initial: true) { _, ready in - guard ready else { return } - didStartSendCash = true - sendCash() - } .onChange(of: composer.draft) { _, _ in saveDraft() } // Mode rather than `replyTarget` alone: it also covers the edit transitions, where what is // worth saving swaps between the field and the draft the edit displaced. @@ -955,7 +859,7 @@ struct ConversationScreen: View { // Donate the open chat for Siri prediction, Handoff, and Spotlight. // Only an existing chat carries an id worth resuming; a contact without // a chat yet has nothing to hand off to. - .userActivity(AppUserActivity.openChat, isActive: chatExists && conversationID != nil) { activity in + .userActivity(AppUserActivity.openChat, isActive: conversationID != nil) { activity in guard let conversationID else { return } activity.title = title activity.userInfo = [AppUserActivity.chatIDKey: conversationID.base64URLEncoded] @@ -1212,13 +1116,6 @@ struct ConversationScreen: View { } } - /// Whether the open-time Send Cash can run and take the branch a tap would. Before the chat - /// exists that waits on the fee: `sendCash()` falls through to the amount screen without it. - private var isReadyToStartSendCash: Bool { - startSendCash && !didStartSendCash && sendTarget != nil - && (chatExists || startChattingFee != nil) - } - private func sendCash() { guard let sendTarget else { return } let context: AddMoneyContext = switch sendTarget { @@ -1230,26 +1127,9 @@ struct ConversationScreen: View { session.dialogItem = dialog return } - // The payment that opens a tip DM can only be the fee, and the bar has - // already named it — so it's confirmed rather than entered. Everything - // else, this one included when the fee hasn't resolved yet, opens the - // amount screen. - if case .tip = sendTarget, let fee = startChattingFee { - startChattingRequest = StartChattingRequest(target: sendTarget, fee: fee) - return - } router.presentSendAmount(sendTarget) } - /// What the start-chatting sheet is opened for, snapshotted at the tap: once - /// the send lands the chat exists and `startChattingFee` goes nil. - private struct StartChattingRequest: Identifiable { - let target: SendTarget - let fee: FiatAmount - - var id: SendTarget { target } - } - /// Re-send a failed message tapped in the transcript. The id is the row's stable id, which for a /// failed message is its client message id. private func retry(messageID: String) { @@ -1496,7 +1376,7 @@ struct ConversationScreen: View { knownAuthors: sessionContainer.knownAuthors, // `profileAvatars` is captured directly so the coordinator retains // one small store, not the whole session container. - profileCard: { [context, contactSyncController, conversationController, session, counterpartUserID, profileAvatars = sessionContainer.profileAvatars] in + profileCard: { [context, contactSyncController, conversationController, session, profileAvatars = sessionContainer.profileAvatars] in // A group cards itself — the address book has nothing to say about a chat, and // the unknown-contact fallback below would flag one as an unknown person. guard conversationController.conversation(withID: id)?.type != .group else { @@ -1507,10 +1387,7 @@ struct ConversationScreen: View { conversationID: id, directory: contactSyncController.resolvedContacts.onFlipcash, controller: conversationController, - profileAvatars: profileAvatars, - // Resolved inside the closure, not captured: the card - // must pick up the conversation the first tip creates. - fallbackCounterpart: Self.cachedCounterpart(counterpartUserID, session: session) + profileAvatars: profileAvatars ) } ) @@ -1525,28 +1402,6 @@ struct ConversationScreen: View { return .handle(username) } - /// The counterpart of a tip DM that has no chat yet, built from a fetched - /// profile. Gives the title, card, and Send Cash target the same member - /// shape a synced conversation would supply. - static func counterpart(userID: UserID, profile: Profile) -> ConversationMember { - // A name-less account can still be tipped, and the chat has to be - // titled either way. The server sends such a name as "", not nil. - let name = profile.displayName.flatMap { $0.isEmpty ? nil : $0 } - return ConversationMember( - userID: userID, - displayName: name ?? profile.username?.handle ?? ConversationController.fallbackCounterpartName, - profilePicture: profile.profilePicture, - username: profile.username - ) - } - - /// ``counterpart(userID:profile:)`` against the profile cache the username - /// lookup writes on its way here. - private static func cachedCounterpart(_ userID: UserID?, session: Session) -> ConversationMember? { - guard let userID, let profile = session.cachedUserProfile(for: userID) else { return nil } - return counterpart(userID: userID, profile: profile) - } - /// The transcript's profile card for the counterpart, resolved live from the directory the /// same way the nav title is: the synced contact when there is one, the profile-only tip /// counterpart for a tip DM, otherwise the counterpart's formatted number flagged as an @@ -1556,8 +1411,7 @@ struct ConversationScreen: View { conversationID: ConversationID, directory: [ResolvedContact], controller: ConversationController, - profileAvatars: ProfileAvatarStore, - fallbackCounterpart: ConversationMember? = nil + profileAvatars: ProfileAvatarStore ) -> ChatProfileCard { if let conversation = controller.conversation(withID: conversationID), conversation.type == .tipDm { @@ -1570,16 +1424,6 @@ struct ConversationScreen: View { counterpart: Self.tipDMCounterpart(counterpart) ) } - // No conversation record yet — the same card, from the cached profile. - if let counterpart = fallbackCounterpart { - return ChatProfileCard( - name: counterpart.displayName, - avatarID: counterpart.userID?.uuidString ?? conversationID.description, - imageData: profileAvatars.data(for: counterpart.userID), - blurhash: counterpart.profilePicture?.thumbnailBlurhash, - counterpart: Self.tipDMCounterpart(counterpart) - ) - } if let contact = context.resolvedContact(in: directory) { return ChatProfileCard( name: contact.displayName, diff --git a/Flipcash/Core/Screens/Conversation/StartChattingSheet.swift b/Flipcash/Core/Screens/Conversation/StartChattingSheet.swift index 7fec0ebde..79215f782 100644 --- a/Flipcash/Core/Screens/Conversation/StartChattingSheet.swift +++ b/Flipcash/Core/Screens/Conversation/StartChattingSheet.swift @@ -33,11 +33,15 @@ struct StartChattingSheet: View { /// opened for once the send lands and the floor no longer applies. let fee: FiatAmount + /// Called once the payment lands, before the sheet's success hold and dismissal. + var didSucceed: () -> Void = {} + var body: some View { StartChattingSheetContent( sessionContainer: sessionContainer, target: target, - fee: fee + fee: fee, + onSuccess: didSucceed ) } } @@ -51,15 +55,18 @@ private struct StartChattingSheetContent: View { @State private var didSucceed: Bool = false private let fee: FiatAmount + private let onSuccess: () -> Void // MARK: - Init - init( sessionContainer: SessionContainer, target: SendTarget, - fee: FiatAmount + fee: FiatAmount, + onSuccess: @escaping () -> Void ) { self.fee = fee + self.onSuccess = onSuccess _viewModel = State(initialValue: SendAmountViewModel( sessionContainer: sessionContainer, target: target @@ -108,6 +115,7 @@ private struct StartChattingSheetContent: View { switch await viewModel.submit(entered: amount.value) { case .success: didSucceed = true + onSuccess() case .recipientNotFound: dismiss() throw StartChattingDismissed() diff --git a/Flipcash/Core/Screens/Main/CameraPromptView.swift b/Flipcash/Core/Screens/Main/CameraPromptView.swift index 0a013823a..01f951b58 100644 --- a/Flipcash/Core/Screens/Main/CameraPromptView.swift +++ b/Flipcash/Core/Screens/Main/CameraPromptView.swift @@ -49,13 +49,13 @@ nonisolated enum CameraPrompt: Equatable { func message(embedded: Bool) -> String { switch self { case .requestPermission: - embedded ? "Start your camera to scan a Tip Card" + embedded ? "Start your camera to scan a Profile Card" : "Flipcash uses your camera to scan and grab cash" case .openSettings: - embedded ? "Turn on Camera in Settings to scan a Tip Card" + embedded ? "Turn on Camera in Settings to scan a Profile Card" : "You need to turn on Camera in Settings to scan Codes" case .startCamera: - embedded ? "Start your camera to scan a Tip Card" + embedded ? "Start your camera to scan a Profile Card" : "You need to start your camera to grab cash" } } diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index bbe034dda..8e06856d0 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -66,16 +66,15 @@ 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, + isShowingProfileCard: router.isShowingProfileCard, hasPushedScreen: selection.pushStack.map { !router[$0].isEmpty } ?? false ) } @@ -102,8 +101,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 +327,6 @@ struct HomeTabView: View { } case .tipCard: TipCardTab() - .environment(tipCardPresentation) } } } @@ -358,14 +354,14 @@ extension HomeTab { /// hiding on it everywhere left that tab with no bar and no way back. func hidesTabBar( isWalletCardExpanded: Bool, - isTipCardExpanded: Bool, isShowingBill: Bool, + isShowingProfileCard: Bool, hasPushedScreen: Bool ) -> Bool { if isShowingBill || hasPushedScreen { return true } switch self { case .wallet: return isWalletCardExpanded - case .tipCard: return isTipCardExpanded + case .tipCard: return isShowingProfileCard case .scan, .chat: return false } } diff --git a/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift b/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift index 7f216b0a2..b867fae2d 100644 --- a/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift +++ b/Flipcash/Core/Screens/Main/Home/NewUserTutorial.swift @@ -23,14 +23,14 @@ nonisolated enum TutorialItem: TutorialItemPresentable { var title: String { switch self { case .addMoney: return "Add Money" - case .scanTipCard: return "Scan a Tip Card" + case .scanTipCard: return "Scan a Profile Card" } } var subtitle: String { switch self { case .addMoney: return "Add money to your account" - case .scanTipCard: return "Give your first tip" + case .scanTipCard: return "Send your first message" } } diff --git a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift index cfaad557d..67c70bb6e 100644 --- a/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/TransactionDetailsScreen.swift @@ -71,15 +71,16 @@ struct TransactionDetailsScreen: View { idCard if let userID = activity.counterparty?.userID, details.canViewInChat { - Button("View in Chat") { + let dmID = sessionContainer.conversationController.tipDMID(withUserID: userID) + Button(dmID == nil ? "View Profile" : "View in Chat") { // Pushed onto the stack this screen is already // on, not routed to the Chat tab: a cross-stack // jump swaps the tab out from under the // transition, so the bar and the conversation - // list both show before the chat lands. Pushed, - // the chat arrives from the entry it belongs to - // and back returns here. - router.push(.tipConversationForUser(userID)) + // list both show before the screen lands. + // Pushed, it arrives from the entry it belongs + // to and back returns here. + router.push(DMRoute.destination(for: userID, dmID: dmID, origin: .transaction)) } .buttonStyle(.filled05) } diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index 773d11c02..412f070da 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -15,7 +15,7 @@ struct WalletScreen: View { @Environment(SessionContainer.self) private var sessionContainer - /// Invoked by the tutorial's "Scan a Tip Card" step to switch to the Scan tab. + /// Invoked by the tutorial's "Scan a Profile Card" step to switch to the Scan tab. let onScanTipCard: () -> Void var body: some View { diff --git a/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift new file mode 100644 index 000000000..17894f688 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift @@ -0,0 +1,170 @@ +// +// ChangeCoverPictureScreen.swift +// Flipcash +// + +import SwiftUI +import UniformTypeIdentifiers +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.profile-cover") + +/// Changing the cover banner on its own, pushed from Edit Profile. Owns its upload state so a +/// cover can never resume an avatar blob. +struct ChangeCoverPictureScreen: View { + + @Environment(Container.self) private var container + @Environment(SessionContainer.self) private var sessionContainer + @Environment(AppRouter.self) private var router + + @State private var state = ProfileCreationState() + @State private var isShowingPhotoPicker = false + @State private var isShowingFilePicker = false + @State private var dialog: DialogItem? + @State private var buttonState: ButtonState = .normal + + private var session: Session { sessionContainer.session } + private var profile: Profile? { session.profile } + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 0) { + Menu { + Button("Photo Library", systemImage: "photo.on.rectangle") { isShowingPhotoPicker = true } + Button("Choose File", systemImage: "folder") { isShowingFilePicker = true } + } label: { + ProfileCoverBanner( + userID: session.userID, + coverPicture: profile?.coverPicture, + preview: state.selectedImage + ) + .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius)) + .overlay { + if state.selectedImage == nil && profile?.coverPicture == nil { + Image(systemName: "plus") + .font(.appDisplayMedium) + .foregroundStyle(Color.textSecondary) + } + } + } + .menuIndicator(.hidden) + .disabled(state.isUploading) + .accessibilityIdentifier("cover-picker") + .padding(.top, 20) + + Spacer() + + Button(action: submit) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + .disabled(!state.canSubmitPhoto || !buttonState.isNormal) + .accessibilityIdentifier("cover-save") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + .navigationTitle("Cover") + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .fullScreenCover(isPresented: $isShowingPhotoPicker) { + ImagePickerWithEditor( + onImagePicked: state.select, + onDismiss: { isShowingPhotoPicker = false } + ) + .ignoresSafeArea() + } + .fileImporter( + isPresented: $isShowingFilePicker, + allowedContentTypes: [.image], + allowsMultipleSelection: false, + onCompletion: handleFileImport + ) + .task(id: state.uploadAttemptID) { + guard state.hasPendingUpload else { return } + await upload() + } + } + + private func submit() { + state.beginUpload() + } + + private func upload() async { + buttonState = .loading + do { + try await state.uploadPhoto( + with: SessionProfilePictureUploader( + session: session, + flipClient: container.flipClient, + slot: .cover + ) + ) + + buttonState = .success + try? await Task.delay(milliseconds: 500) + guard !Task.isCancelled else { return } + + state.releaseSelectedImage() + router.popTopmost() + + } catch let error as ErrorBlob { + buttonState = .normal + guard !Task.isCancelled else { return } + logger.info("Cover upload failed", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Cover upload failed", userFacing: true) + dialog = .profilePictureFailed(error) + + } catch let error as ImageEncoderError { + buttonState = .normal + logger.error("Failed to encode the cover", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to encode the cover") + dialog = .imageProcessingFailed + + } catch { + buttonState = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set cover picture", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set cover picture") + dialog = .error( + title: "Couldn't Upload Your Cover", + subtitle: "Try again" + ) + } + } + + private func handleFileImport(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + logger.info("Cover file import failed", metadata: ["error": "\(error)"]) + + case .success(let urls): + guard let url = urls.first else { return } + importImage(at: url) + } + } + + private func importImage(at url: URL) { + Task { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + let image = await Task.detached(priority: .userInitiated) { () -> UIImage? in + guard let data = try? Data(contentsOf: url) else { return nil } + return UIImage(data: data) + }.value + + guard let image else { + dialog = .error( + title: "Couldn't Open That File", + subtitle: "Try a different image" + ) + return + } + + state.select(image) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift b/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift new file mode 100644 index 000000000..9168d96d5 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift @@ -0,0 +1,127 @@ +// +// EditBioModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.edit-bio") + +/// The bio editor's state and its one save. +/// +/// The calls are injected: `FlipClient` is a concrete class with a live gRPC channel, so a test +/// has nothing to fake. +@MainActor +@Observable +final class EditBioModel { + + /// What the server refused the bio for, shown under the field. Cleared by the next edit. + enum FieldError: Equatable { + case moderated + case invalid + + var message: String { + switch self { + case .moderated: "This bio isn't allowed. Try different wording" + case .invalid: "This bio isn't valid" + } + } + } + + /// Where the save is. `.saved` is held by the screen for its checkmark before it pops. + enum SaveState: Equatable { + case normal + case saving + case saved + } + + static let maxLength = 160 + + /// The bio as typed. Editing it clears ``fieldError``. + var text: String { + didSet { + guard text != oldValue else { return } + fieldError = nil + } + } + + private(set) var state: SaveState = .normal + private(set) var fieldError: FieldError? + + /// A failure with no field to blame, shown as a dialog. Set to nil once shown. + var failure: Error? + + @ObservationIgnored private let initialText: String + @ObservationIgnored private let validator = LengthValidator(maxLength: maxLength) + @ObservationIgnored private let saving: (String) async throws -> Void + @ObservationIgnored private let refresh: () async throws -> Void + + /// - Parameters: + /// - bio: the bio the profile carries now. + /// - saving: submits the trimmed bio. + /// - refresh: re-reads the profile so the rest of the app sees the new bio. + init( + bio: String, + saving: @escaping (String) async throws -> Void, + refresh: @escaping () async throws -> Void + ) { + self.text = bio + self.initialText = bio + self.saving = saving + self.refresh = refresh + } + + /// How many more characters fit, negative once over the limit. + var remaining: Int { + Self.maxLength - text.count + } + + /// Whether Save is enabled. Clearing the bio to empty is a change like any other. + var canSave: Bool { + state == .normal && submission != nil && submission != Self.trim(initialText) + } + + /// The bio as it would be submitted, or nil while it is over the limit. Empty is valid, unlike + /// for `LengthValidator`, which rejects blank input: clearing the bio is an edit. + private var submission: String? { + let trimmed = Self.trim(text) + return trimmed.isEmpty ? trimmed : validator.validate(trimmed) + } + + private static func trim(_ string: String) -> String { + string.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Submits the bio, then re-reads the profile. Failures land in ``fieldError`` or ``failure``. + func save() async { + guard canSave, let submission else { return } + + state = .saving + fieldError = nil + + do { + try await saving(submission) + try await refresh() + state = .saved + + } catch ErrorProfile.moderated(let category) { + state = .normal + logger.info("Bio moderation denied", metadata: ["category": "\(category)"]) + fieldError = .moderated + + } catch ErrorProfile.invalidBio { + state = .normal + logger.info("Bio rejected as invalid") + fieldError = .invalid + + } catch { + state = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set bio", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set bio") + failure = error + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift new file mode 100644 index 000000000..ab16141a1 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift @@ -0,0 +1,104 @@ +// +// EditBioScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Editing the bio on its own, pushed from Edit Profile. +struct EditBioScreen: View { + + @Environment(AppRouter.self) private var router + + @State private var model: EditBioModel + @State private var dialog: DialogItem? + @State private var saveTask: Task? + + @FocusState private var isFocused: Bool + + /// Takes the model rather than building it, so the field opens on the bio it is about to + /// replace instead of animating it in over the push. + init(model: EditBioModel) { + _model = State(initialValue: model) + } + + private var buttonState: ButtonState { + switch model.state { + case .normal: .normal + case .saving: .loading + case .saved: .success + } + } + + var body: some View { + @Bindable var model = model + + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 0) { + TextField("Add a bio", text: $model.text, axis: .vertical) + .font(.appDisplayXS) + .foregroundStyle(Color.textMain) + .lineLimit(3...8) + .focused($isFocused) + .disabled(model.state != .normal) + .accessibilityIdentifier("edit-bio-field") + .padding(.top, 20) + + Text("\(model.remaining)") + .font(.appTextSmall) + .foregroundStyle(model.remaining < 0 ? Color.textError : Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.top, 8) + .accessibilityIdentifier("edit-bio-counter") + + if let fieldError = model.fieldError { + Text(fieldError.message) + .font(.appTextSmall) + .foregroundStyle(Color.textError) + .padding(.top, 8) + .accessibilityIdentifier("edit-bio-error") + } + + Spacer() + + Button(action: save) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + .disabled(!model.canSave) + .accessibilityIdentifier("edit-bio-save") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .navigationTitle("Bio") + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .focusAfterPush($isFocused) + .onChange(of: model.failure == nil) { _, isNil in + guard !isNil else { return } + model.failure = nil + dialog = .error(title: "Couldn't Save Your Bio", subtitle: "Try again") + } + // Leaving the screen abandons the submission: its only continuation is a pop. + .onDisappear { saveTask?.cancel() } + } + + private func save() { + guard saveTask == nil else { return } + isFocused = false + saveTask = Task { + await model.save() + if model.state == .saved { + // Same beat the other editors hold their checkmark for. + try? await Task.delay(milliseconds: 500) + guard !Task.isCancelled else { return } + router.popTopmost() + } + saveTask = nil + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift new file mode 100644 index 000000000..bddbd464f --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift @@ -0,0 +1,167 @@ +// +// EditFeaturedGroupsModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.edit-featured-groups") + +/// The favorite-groups picker's choices, ordered selection and its one save. +/// +/// The calls are injected: `FlipClient` is a concrete class with a live gRPC channel, so a test +/// has nothing to fake. +@MainActor +@Observable +final class EditFeaturedGroupsModel { + + /// Where the save is. `.saved` is held by the screen for its checkmark before it pops. + enum SaveState: Equatable { + case normal + case saving + case saved + } + + /// Where the candidate list is. + enum LoadState: Equatable { + case loading + case loaded + /// The featured list could not be read, so a save could drop groups the screen never saw. + case failed + } + + /// Why a save failed, for the dialog. + enum Failure: Equatable { + /// The server refused a group as private. It does not say which. + case privateGroup + case other + } + + /// The groups on offer: the ones featured now, in their order, then the public groups the user + /// has joined. + private(set) var candidates: [Conversation] + + /// The chosen groups, in the order they will show on the profile. + private(set) var selection: [ConversationID] + + private(set) var state: SaveState = .normal + private(set) var loadState: LoadState = .loading + + /// Filters ``visibleCandidates`` by title. + var query = "" + + /// A failed save, shown as a dialog. Set to nil once shown. + var failure: Failure? + + @ObservationIgnored private var initialSelection: [ConversationID] + @ObservationIgnored private let loadingFeatured: () async -> [Conversation]? + @ObservationIgnored private let joinedGroups: () async -> [Conversation] + @ObservationIgnored private let saving: ([ConversationID]) async throws -> [Conversation] + @ObservationIgnored private let saved: ([Conversation]) -> Void + + /// - Parameters: + /// - featured: the groups the profile featured when last read, shown while they are re-read. + /// - loadingFeatured: re-reads the featured groups, in order; nil when that fails. + /// - joinedGroups: the groups the user is in, as the server says now. + /// - saving: submits the ordered selection and returns what is now featured. + /// - saved: hands that list to the rest of the app. + init( + featured: [Conversation], + loadingFeatured: @escaping () async -> [Conversation]?, + joinedGroups: @escaping () async -> [Conversation], + saving: @escaping ([ConversationID]) async throws -> [Conversation], + saved: @escaping ([Conversation]) -> Void + ) { + self.candidates = featured + self.selection = featured.map(\.id) + self.initialSelection = featured.map(\.id) + self.loadingFeatured = loadingFeatured + self.joinedGroups = joinedGroups + self.saving = saving + self.saved = saved + } + + /// The candidates whose title matches ``query``. + var visibleCandidates: [Conversation] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return candidates } + return candidates.filter { $0.groupLinkTitle.localizedCaseInsensitiveContains(query) } + } + + /// Whether Save is enabled: only once the featured list has been read, so a save never drops + /// groups the screen did not know about. Clearing every group is a change like any other. + var canSave: Bool { + state == .normal && loadState == .loaded && selection != initialSelection + } + + /// Whether the group is chosen. + func isSelected(_ id: ConversationID) -> Bool { + selection.contains(id) + } + + /// Whether tapping the group would change anything: always for a chosen one, and for the rest + /// only while there is room under ``FeaturedGroups/limit``. + func canToggle(_ id: ConversationID) -> Bool { + state == .normal && (isSelected(id) || selection.count < FeaturedGroups.limit) + } + + /// Chooses the group at the end of the order, or drops it. + func toggle(_ id: ConversationID) { + guard canToggle(id) else { return } + if let index = selection.firstIndex(of: id) { + selection.remove(at: index) + } else { + selection.append(id) + } + } + + /// Re-reads the featured groups, then offers the user's joined public groups after them. + /// + /// Featured groups stay on offer even when the user has left them: leaving does not unfeature + /// a group, so dropping it from the list would leave no way to remove it. + func loadCandidates() async { + guard let featured = await loadingFeatured() else { + loadState = .failed + return + } + let featuredIDs = featured.map(\.id) + // A choice made while the list was loading stands; an untouched one follows the server. + if selection == initialSelection { + selection = featuredIDs + } + initialSelection = featuredIDs + + let joined = await joinedGroups() + let known = Set(featuredIDs) + candidates = featured + joined.filter { $0.type == .group && !$0.isPrivate && !known.contains($0.id) } + loadState = .loaded + } + + /// Submits the selection. Failures land in ``failure``. + func save() async { + guard canSave else { return } + + state = .saving + + do { + let result = try await saving(selection) + saved(result) + state = .saved + + } catch let error as ErrorSetFeaturedGroups where error == .denied { + state = .normal + logger.info("Featured groups denied") + ErrorReporting.captureError(error, reason: "Featured groups denied") + failure = .privateGroup + + } catch { + state = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set featured groups", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set featured groups") + failure = .other + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift new file mode 100644 index 000000000..58ab3b392 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -0,0 +1,157 @@ +// +// EditFeaturedGroupsScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Choosing the public groups the profile features, pushed from Edit Profile. +struct EditFeaturedGroupsScreen: View { + + @Environment(AppRouter.self) private var router + + @State private var model: EditFeaturedGroupsModel + @State private var dialog: DialogItem? + @State private var saveTask: Task? + + /// Takes the model rather than building it, so the list opens on the groups featured now. + init(model: EditFeaturedGroupsModel) { + _model = State(initialValue: model) + } + + private var buttonState: ButtonState { + switch model.state { + case .normal: .normal + case .saving: .loading + case .saved: .success + } + } + + var body: some View { + @Bindable var model = model + + Background(color: .backgroundMain) { + list + .scrollEdgeBar(.bottom) { + Button(action: save) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + // The disabled fill is translucent; without this the rows show through it. + .background(Color.backgroundMain, in: RoundedRectangle(cornerRadius: Metrics.buttonRadius)) + .disabled(!model.canSave) + .accessibilityIdentifier("edit-featured-groups-save") + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) + } + } + .navigationTitle("Favorite Groups") + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $model.query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search your public groups" + ) + .dialog(item: $dialog) + .task { await model.loadCandidates() } + .onChange(of: model.failure) { _, failure in + guard let failure else { return } + model.failure = nil + switch failure { + case .privateGroup: + dialog = .error(title: "Only Public Groups Can Be Featured", subtitle: "One of these groups is private. Remove it and try again") + case .other: + dialog = .error(title: "Couldn't Save Your Groups", subtitle: "Try again") + } + } + // Leaving the screen abandons the submission: its only continuation is a pop. + .onDisappear { saveTask?.cancel() } + } + + private var list: some View { + List { + Section { + // The list appears whole once the feed is read, rather than growing from the + // featured groups. The List itself stays so the search field keeps its place. + if model.loadState != .loading { + ForEach(model.visibleCandidates) { group in + row(group) + } + } + } header: { + ListHeader("\(model.selection.count) of \(FeaturedGroups.limit) selected") + } footer: { + if model.loadState != .loading, !model.candidates.isEmpty { + Text("These public groups will appear on your public profile") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20)) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .overlay { + if model.loadState == .loading { + ProgressView() + .tint(Color.textSecondary) + } else { + emptyState + } + } + } + + private func row(_ group: Conversation) -> some View { + Button { + model.toggle(group.id) + } label: { + FeaturedGroupRow(group: group) { + CheckView(active: model.isSelected(group.id)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(model.canToggle(group.id) ? 1 : 0.4) + .disabled(!model.canToggle(group.id)) + .listRowBackground(Color.backgroundMain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(group.featuredRowAccessibilityLabel)) + .accessibilityAddTraits(model.isSelected(group.id) ? [.isButton, .isSelected] : .isButton) + .accessibilityIdentifier("edit-featured-groups-row") + } + + @ViewBuilder + private var emptyState: some View { + if !model.query.isEmpty, model.visibleCandidates.isEmpty { + SearchResultsUnavailableView(searchText: model.query) + } else if model.loadState == .failed { + Text("Couldn't load your groups") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + } else if model.loadState == .loaded, model.candidates.isEmpty { + Text("Join a public group to feature it on your profile") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } + + private func save() { + guard saveTask == nil else { return } + saveTask = Task { + await model.save() + if model.state == .saved { + // Same beat the other editors hold their checkmark for. + try? await Task.delay(milliseconds: 500) + guard !Task.isCancelled else { return } + router.popTopmost() + } + saveTask = nil + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift new file mode 100644 index 000000000..49a134465 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -0,0 +1,249 @@ +// +// EditProfileScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The signed-in user's profile fields as cards under an inset cover and photo, each opening its own +/// editor. Pushed from the You tab's Edit Profile button. +struct EditProfileScreen: View { + + @Environment(AppRouter.self) private var router + @Environment(SessionContainer.self) private var sessionContainer + @Environment(RatesController.self) private var ratesController + + @State private var dialog: DialogItem? + + private static let coverHeight: CGFloat = 126 + private static let avatarSize: CGFloat = 68 + private static let cardSpacing: CGFloat = 12 + + private var session: Session { sessionContainer.session } + private var profile: Profile? { session.profile } + + /// Whether the handle still needs claiming: none yet, or one the server assigned. + private var needsUsernameClaim: Bool { + usernameNeedsClaim( + username: profile?.username, + isAutoAssigned: profile?.isUsernameAutoAssigned == true + ) + } + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + cover + photo + fieldCards + .padding(.top, 20) + } + .padding(.horizontal, ProfileHeaderView.inset) + .padding(.vertical, 24) + } + } + .navigationTitle("Edit Profile") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .task(id: profile?.profilePicture?.thumbnailBlobID) { + await sessionContainer.profileAvatars.load(userID: session.userID, picture: profile?.profilePicture) + } + } + + // MARK: - Cover & photo - + + private var cover: some View { + Button { + router.push(.changeCoverPicture) + } label: { + ProfileCoverBanner(userID: session.userID, coverPicture: profile?.coverPicture, bannerHeight: Self.coverHeight) + .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-profile-cover") + .overlay(alignment: .topTrailing) { + Button("Change cover") { + router.push(.changeCoverPicture) + } + .buttonStyle(.plain) + .modifier(CoverChip()) + .padding(Self.cardSpacing) + .accessibilityIdentifier("edit-profile-change-cover") + } + } + + /// The avatar overlapping the cover's bottom edge, with a camera badge and a caption beside it. + private var photo: some View { + Button { + router.push(.changeProfilePicture) + } label: { + HStack(alignment: .bottom, spacing: Self.cardSpacing) { + ContactAvatarView( + id: session.userID.uuidString, + displayName: profile?.displayName ?? "", + imageData: sessionContainer.profileAvatars.data(for: session.userID), + blurhash: profile?.profilePicture?.thumbnailBlurhash, + size: Self.avatarSize + ) + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } + .overlay(alignment: .bottomTrailing) { cameraBadge } + + Text("Change photo") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .padding(.bottom, 6) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-profile-photo") + .padding(.top, -Self.avatarSize / 2) + .padding(.leading, 16) + } + + private var cameraBadge: some View { + Image.asset(.camera) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .frame(width: 28, height: 28) + // Opaque so the avatar doesn't show through; the same grey as the header's buttons. + .background(Circle().fill(Color.action.opacity(0.1))) + .background(Circle().fill(Color.backgroundMain)) + .offset(x: 6, y: 2) + } + + // MARK: - Fields - + + private var fieldCards: some View { + VStack(spacing: Self.cardSpacing) { + FieldCard(title: "Name", value: nonEmpty(profile?.displayName), placeholder: "Add a name") { + router.push(.changeDisplayName) + } + .accessibilityIdentifier("edit-profile-name") + + usernameCard + + FieldCard(title: "Bio", value: nonEmpty(profile?.bio), placeholder: "Add a bio", lineLimit: 3) { + router.push(.editBio) + } + .accessibilityIdentifier("edit-profile-bio") + + FieldCard(title: "Minimum to Chat", value: minimumToChat, placeholder: "Not set") { + router.push(.setMinimumTip(isSetupStep: false)) + } + .accessibilityIdentifier("edit-profile-minimum") + + FieldCard(title: "Favorite Public Groups", value: featuredGroupsSummary, placeholder: "Add groups") { + router.push(.editFeaturedGroups) + } + .accessibilityIdentifier("edit-profile-featured-groups") + } + } + + @ViewBuilder + private var usernameCard: some View { + if needsUsernameClaim { + FieldCard(title: "Username", value: "Claim your username", placeholder: "", valueIsPrompt: true, action: beginUsernameClaim) + .accessibilityIdentifier("edit-profile-username") + } else if let username = profile?.username { + // No balance gate on a held handle: the gate exists to stop squatting at claim time, + // and a user who already holds one has cleared it. + FieldCard(title: "Username", value: "@\(username.value)", placeholder: "") { + router.push(.username(username)) + } + .accessibilityIdentifier("edit-profile-username") + } + } + + /// How many groups the profile features, or nil for none. + private var featuredGroupsSummary: String? { + let count = sessionContainer.featuredGroups.groups.count + guard count > 0 else { return nil } + return count == 1 ? "1 group" : "\(count) groups" + } + + /// What others pay to start a chat, matching the You tab's stats card. + private var minimumToChat: String? { + StartChattingFee.amount(for: profile, session: session, ratesController: ratesController)?.formatted() + } + + private func nonEmpty(_ string: String?) -> String? { + guard let string, !string.isEmpty else { return nil } + return string + } + + // MARK: - Username claim - + + /// The same entry the You tab's progress card takes: the claim screen once the balance clears + /// the server's minimum, the balance dialog otherwise. + private func beginUsernameClaim() { + switch usernameGate(session: session, minimum: session.userFlags?.usernameMinBalance) { + case .proceed: + router.push(.username(profile?.username)) + case .addMoney(let minimum, _, _): + dialog = .usernameMinimumBalance(minimum: minimum) { + router.presentAddMoney(.general, source: .usernameShortfall) + } + } + } +} + +/// A tappable card with the field's title over its current value, styled like the profile's stats card. +private struct FieldCard: View { + + let title: String + let value: String? + let placeholder: String + var valueIsPrompt = false + var lineLimit = 1 + let action: VoidAction + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + Text(value ?? placeholder) + .font(.appTextMedium) + .foregroundStyle(value == nil || valueIsPrompt ? Color.textSecondary : Color.textMain) + .lineLimit(lineLimit) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + } +} + +/// The "Change cover" button over the cover photo: an interactive Liquid Glass capsule on iOS 26, and on iOS 18 an +/// opaque chip, since the standard chip's row fill is translucent and vanishes on a photo. +private struct CoverChip: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .glassEffect(.regular.tint(Color.backgroundMain.opacity(0.6)).interactive(), in: .capsule) + } else { + content.chip(.tinted(.textMain, on: .backgroundMain)) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift b/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift index 5ffacefaf..def99b9b8 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift @@ -115,7 +115,7 @@ final class ProfileCreationState { throw ErrorBlob.rejected(reason) } - try await uploader.setProfilePicture(blobID: blobID) + try await uploader.attach(blobID: blobID) try await uploader.refreshProfile() } diff --git a/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift b/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift index 7bd08ba00..65363c06e 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift @@ -89,7 +89,7 @@ struct ProfileNameScreen: View { } .navigationBarTitleDisplayMode(.inline) .dialog(item: $dialog) - .onAppear { isNameFocused = true } + .focusAfterPush($isNameFocused) // Leaving the screen abandons the submission: its only continuation is a // push onto a stack this screen no longer sits on. .onDisappear { submitTask?.cancel() } diff --git a/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift b/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift index 71b33b779..2b2df4ce1 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift @@ -153,7 +153,8 @@ struct ProfilePhotoScreen: View { try await state.uploadPhoto( with: SessionProfilePictureUploader( session: sessionContainer.session, - flipClient: container.flipClient + flipClient: container.flipClient, + slot: .avatar ) ) diff --git a/Flipcash/Core/Screens/Main/Profile/ProfilePictureUploading.swift b/Flipcash/Core/Screens/Main/Profile/ProfilePictureUploading.swift index de7186fd8..c67143ef5 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfilePictureUploading.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfilePictureUploading.swift @@ -18,18 +18,27 @@ protocol ProfilePictureUploading { /// is refused and `ErrorBlob.timedOut` when it is still processing. func awaitBlobFinalization(blobID: BlobID) async throws - /// Attaches the finalized blob to the caller's profile. - func setProfilePicture(blobID: BlobID) async throws + /// Attaches the finalized blob to the profile slot this uploader targets. + func attach(blobID: BlobID) async throws /// Re-reads the profile so the rest of the app sees the new picture. func refreshProfile() async throws } +/// Which picture on the profile an upload replaces. +enum ProfilePictureSlot: Sendable { + /// The round profile picture. + case avatar + /// The banner behind it. + case cover +} + /// Uploads on behalf of the signed-in owner. struct SessionProfilePictureUploader: ProfilePictureUploading { let session: Session let flipClient: FlipClient + let slot: ProfilePictureSlot func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair) @@ -39,8 +48,29 @@ struct SessionProfilePictureUploader: ProfilePictureUploading { try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) } - func setProfilePicture(blobID: BlobID) async throws { - try await flipClient.setProfilePicture(blobID: blobID, owner: session.ownerKeyPair) + func attach(blobID: BlobID) async throws { + let owner = session.ownerKeyPair + try await Self.attach( + blobID, + to: slot, + setAvatar: { try await flipClient.setProfilePicture(blobID: $0, owner: owner) }, + // The `refreshProfile` that follows re-reads the cover, so the echo is dropped. + setCover: { _ = try await flipClient.setCoverPicture(blobID: $0, owner: owner) } + ) + } + + /// Routes `blobID` to the call for `slot`. Closures stand in for `FlipClient`, a concrete + /// class with a live gRPC channel, so a test can observe the routing. + static func attach( + _ blobID: BlobID, + to slot: ProfilePictureSlot, + setAvatar: (BlobID) async throws -> Void, + setCover: (BlobID) async throws -> Void + ) async throws { + switch slot { + case .avatar: try await setAvatar(blobID) + case .cover: try await setCover(blobID) + } } func refreshProfile() async throws { diff --git a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift index 40b6abe53..be912356b 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift @@ -11,8 +11,7 @@ private let logger = Logger(label: "flipcash.tip-flow") /// Session-scoped orchestrator for opening someone's tipcard. Entered from a /// scanned tipcode or a legacy `/tip/` link, it gates the entry (profile), resolves -/// the recipient, shows the card, and hands over to their chat — where the -/// amount is chosen and sent (node 10074:18893). +/// the recipient, shows the card, and hands over to their profile. @Observable final class TipFlow { @@ -22,7 +21,7 @@ final class TipFlow { @ObservationIgnored private var prepTask: Task? - /// The hold between the card landing and the chat opening. Held so a + /// The hold between the card landing and the profile opening. Held so a /// dismissed card cancels the hand-off, and so a second entry can't start /// while one is mid-flight. @ObservationIgnored private var routeTask: Task? @@ -50,17 +49,15 @@ 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 - /// giveable-balance check belongs to the send the chat makes, and - /// `ConversationScreen.sendCash()` already applies it there — gating the - /// card as well would stop an empty-balance user from reaching a - /// conversation they can read and reply in. + /// No balance gate: this flow no longer moves money, it opens a profile. + /// The giveable-balance check belongs to the payment that starts the chat, + /// which the profile applies. 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 { @@ -154,9 +151,8 @@ final class TipFlow { } guard !Task.isCancelled else { return } - // The chat is created by the first tip, so until then the - // conversation's only source for the counterpart's name, - // picture, and handle is the profile this resolve fetched. + // The profile screen reads this instead of fetching again; see + // `UserProfileOrigin.arrivesFetched`. session.cacheUserProfile(profile, for: userID) present(userID: userID, profile: profile) await loadAvatar(userID: userID, picture: profile.profilePicture) @@ -176,18 +172,17 @@ final class TipFlow { /// read, so a miss is a fetch that didn't land rather than a wrong address. static var failureDialog: DialogItem { .error( - title: "Couldn't Open Tip Card", + title: "Couldn't Open Profile Card", subtitle: "Please check your connection and try again" ) } /// Shows the resolved card, holds it long enough to read whose it is, then - /// pops it away as the chat with them pushes in underneath. + /// pops it away as their profile pushes in underneath. /// /// The card is the confirmation that the right code was scanned, not a - /// place to compose from: the amount is chosen in the chat, behind the - /// "Start Chatting" CTA, which is where the username lookup already lands - /// (node 10074:18893). + /// place to compose from. The profile is where the chat starts, and it is + /// where a scan lands even once the DM exists, offering Open Chat there. private func present(userID: UserID, profile: Profile) { // The card is resolved and about to show, whether reached from a scan or // a deep link — the second step of the Scanned → Presented → Sent Tip funnel. @@ -199,9 +194,9 @@ final class TipFlow { keyboard.suppress() // A tip deep link can beat the app's foreground stream refresh, so kick the - // rate stream to reconnect now, while the card animates in. The chat's CTA - // names the fee in the display currency and its amount screen priced in it, - // so both want a rate the moment they appear. + // rate stream to reconnect now, while the card animates in. The profile's + // pinned button names the fee in the display currency, so it wants a rate + // the moment it appears. ratesController.ensureStreamConnected() session.billState = BillState(bill: .tipcard( @@ -213,11 +208,11 @@ final class TipFlow { session.presentationState = .visible(.pop) // The card's pop is given 750ms to settle and be read, then it leaves - // and the chat arrives together: the dismissal and the route happen in - // the same tick, so the card's 100ms exit plays over the chat pushing + // and the profile arrives together: the dismissal and the route happen in + // the same tick, so the card's 100ms exit plays over the profile pushing // in rather than handing back to the camera in between. Cancelled by // `cancel()`, so a card the user drags away during the hold never drops - // them into a chat they backed out of. + // them onto a profile they backed out of. routeTask = Task { [weak self] in defer { self?.routeTask = nil } try? await Task.delay(milliseconds: 750) @@ -228,10 +223,11 @@ final class TipFlow { } // `navigate` rather than `push`: the card is an app-root overlay - // raised over whichever tab the scan or link arrived on, and a tip - // DM belongs on the Tips stack. This brings that tab forward with - // the chat as its only entry, so Back lands on the chat list. - router.navigate(to: .tipConversationForUser(userID)) + // raised over whichever tab the scan or link arrived on, and the DM + // the profile leads to belongs on the Tips stack. This brings that + // tab forward with the profile as its only entry, so Back lands on + // the chat list. + router.navigate(to: .userProfile(userID, origin: .scan)) } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipcardScreen.swift b/Flipcash/Core/Screens/Main/Tips/TipcardScreen.swift index 86583f51c..50dee51ff 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipcardScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipcardScreen.swift @@ -28,7 +28,7 @@ struct TipcardScreen: View { Background(color: .backgroundMain) { legacyContent } - .navigationTitle("My Tip Card") + .navigationTitle("My Profile Card") .toolbarTitleDisplayMode(.inline) .task(id: profilePicture?.thumbnailBlobID) { previewCache.warm(TipCode.Payload(userID: sessionContainer.session.userID)) @@ -39,7 +39,7 @@ struct TipcardScreen: View { private var legacyContent: some View { VStack(spacing: 0) { - Text("Share Your Tip Card to Get Tipped") + Text("Share Your Profile Card to Get Tipped") .font(.appTextLarge) .foregroundStyle(Color.textMain) .multilineTextAlignment(.center) diff --git a/Flipcash/Core/Screens/Main/Username/UsernameEntryScreen.swift b/Flipcash/Core/Screens/Main/Username/UsernameEntryScreen.swift index 716e9547c..3274a2951 100644 --- a/Flipcash/Core/Screens/Main/Username/UsernameEntryScreen.swift +++ b/Flipcash/Core/Screens/Main/Username/UsernameEntryScreen.swift @@ -90,10 +90,8 @@ struct UsernameEntryScreen: View { } .navigationBarTitleDisplayMode(.inline) .dialog(item: $dialog) - .onAppear { - input = currentUsername?.value ?? "" - isFocused = true - } + .onAppear { input = currentUsername?.value ?? "" } + .focusAfterPush($isFocused) // Leaving abandons the submission: its only continuation pops a stack // this screen no longer sits on. .onDisappear { submitTask?.cancel() } diff --git a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift index b4eada42f..cf7df413a 100644 --- a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift +++ b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift @@ -94,7 +94,7 @@ struct UsernameLookupScreen: View { } .navigationBarTitleDisplayMode(.inline) .dialog(item: $errorDialog) - .onAppear { isFocused = true } + .focusAfterPush($isFocused) // Backing out abandons the lookup rather than pushing a chat onto a // stack the user has left. Fires on the hand-off too, once the chat // covers this screen, which is why the hand-off detaches itself first. @@ -141,16 +141,15 @@ struct UsernameLookupScreen: View { buttonState = .success try await Task.delay(milliseconds: 500) - // Your own handle has no chat to open — one tipcard is what - // scanning your own code already shows. + // Your own handle has no chat to open — scanning your own code + // already shows your own profile. guard userID != session.userID else { sessionContainer.tipFlow.begin(userID: userID) return } - // The chat is created by the first tip, so until then the - // screen's only source for the counterpart's name, picture, and - // handle is the profile this lookup just fetched. + // The profile screen reads this instead of fetching again, and a + // chat with no record yet reads its counterpart from it. session.cacheUserProfile(profile, for: userID) // Detached before the push: covering this screen fires the @@ -158,16 +157,22 @@ struct UsernameLookupScreen: View { // that has to run anyway. lookupTask = nil - let chat = AppRouter.Destination.tipConversationForUser(userID) + // The DM once it exists, the profile until then: the profile is + // where the chat starts. + let destination = DMRoute.destination( + for: userID, + dmID: sessionContainer.conversationController.tipDMID(withUserID: userID), + origin: .usernameLookup + ) // Measured rather than assumed: this screen is reached through // the New Chat picker today and was reached straight off the // chat list before it, so a fixed depth silently stops matching // the moment a screen is added to or removed from the route. - let depthWithChat = router[.tips].count + 1 - router.push(chat) + let depthWithDestination = router[.tips].count + 1 + router.push(destination) - // Well inside the push transition, so the remounted chat settles + // Well inside the push transition, so the remounted screen settles // behind the animation rather than in front of the user. Late // enough that SwiftUI has committed the push as its own update: // rewriting in the same tick coalesces the two into a leaf swap, @@ -175,17 +180,17 @@ struct UsernameLookupScreen: View { try await Task.delay(milliseconds: 150) // Unless they went back while it landed: the lookup left with - // them, and rewriting the path would drag the chat back. - guard router[.tips].count == depthWithChat else { return } + // them, and rewriting the path would drag the screen back. + guard router[.tips].count == depthWithDestination else { return } // Unanimated, because nothing is arriving — only the entry // underneath is leaving. Animated, SwiftUI stages the remount as - // a second push: the same chat sliding in over the one already + // a second push: the same screen sliding in over the one already // on screen. var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { - router.setPath([chat], on: .tips) + router.setPath([destination], on: .tips) } } catch { diff --git a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift new file mode 100644 index 000000000..b7a112f83 --- /dev/null +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -0,0 +1,165 @@ +// +// ProfileCardScreen.swift +// Flipcash +// + +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashUI + +/// The user's profile card over the You tab, with Close pinned to the bottom. Drawn +/// inside the tab rather than as a cover so the tab's own toolbar button, swapped to +/// Download, stays the only glass in the corner. +struct ProfileCardScreen: View { + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(AppRouter.self) private var router + + /// Raised by the You tab's toolbar button while the card is up. + @Binding var isShowingDownloadOptions: Bool + + /// 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 backdrop and Close fade in on this; removal fades the whole overlay instead. + @State private var isRevealed = false + /// The card's scale springs on this, separately, so it can bounce while its opacity eases with the backdrop. + @State private var isCardShown = false + + /// 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 + + /// The scanned-card pop from `BillCanvas` (0.55 scale, 0.4s at 0.4 damping). + private static let revealScale: CGFloat = 0.55 + private static let revealSpring: Animation = .spring(duration: 0.4, bounce: 0.6) + + /// The open and close fade, shared with the toolbar glyph swap so the two move together. + static let fade: Animation = .easeOut(duration: 0.25) + + var body: some View { + ZStack { + Color.backgroundMain + .ignoresSafeArea() + .opacity(isRevealed ? 1 : 0) + + ScrollView(showsIndicators: false) { + if let name = displayName { + TipcardView( + size: cardSize, + name: name, + avatar: nil, + codeData: codeData, + tintOpacity: 0.36, + subtitle: username.map(\.handle) + ) + .scaleEffect(isCardShown ? 1 : Self.revealScale) + .opacity(isRevealed ? 1 : 0) + .padding(.horizontal, Self.horizontalInset) + .containerRelativeFrame(.vertical, alignment: .center) + } + } + } + .safeAreaInset(edge: .bottom) { + Button("Close", action: close) + .buttonStyle(.subtle) + .opacity(isRevealed ? 1 : 0) + .accessibilityIdentifier("profile-card-close") + .padding(.horizontal, Self.horizontalInset) + } + .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { + TipCardDownloadSheet( + onSelect: { pendingDownload = $0; isShowingDownloadOptions = false }, + onCancel: { isShowingDownloadOptions = false } + ) + } + .onAppear { + boostBrightness() + reveal() + } + .onDisappear(perform: restoreBrightness) + } + + // 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 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 - + + /// 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: - Reveal - + + private func reveal() { + Haptics.vibrate() + withAnimation(Self.fade) { isRevealed = true } + withAnimation(Self.revealSpring) { isCardShown = true } + } + + /// Fades the card out at full size; the overlay's removal transition carries the fade. + private func close() { + withAnimation(Self.fade) { router.isShowingProfileCard = false } + } + + // 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/ProfileTutorial.swift b/Flipcash/Core/Screens/Main/You/ProfileTutorial.swift deleted file mode 100644 index 513eb13d4..000000000 --- a/Flipcash/Core/Screens/Main/You/ProfileTutorial.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// ProfileTutorial.swift -// Flipcash -// - -import SwiftUI -import FlipcashCore -import FlipcashUI - -/// A chore in the You tab's "Finish Your Profile" checklist (Figma node -/// 9544:18140). -nonisolated enum ProfileTutorialItem: TutorialItemPresentable { - case displayName(isCompleted: Bool) - case profilePicture(isCompleted: Bool) - case minimumTipAmount(isCompleted: Bool) - - var id: String { title } - - var isCompleted: Bool { - switch self { - case .displayName(let done), .profilePicture(let done), .minimumTipAmount(let done): return done - } - } - - var title: String { - switch self { - case .displayName: return "Add your display name" - case .profilePicture: return "Add a profile picture" - case .minimumTipAmount: return "Set your minimum to chat" - } - } - - var subtitle: String { - switch self { - case .displayName: return "Choose the name people see in chat" - case .profilePicture: return "Select a photo from your gallery" - case .minimumTipAmount: return "Decide what someone must send to start chatting" - } - } - - @MainActor var icon: Image { - switch self { - case .displayName: return .asset(.pencil) - case .profilePicture: return .asset(.peopleCircle) - case .minimumTipAmount: return .asset(.coins) - } - } -} - -/// Whether the You tab draws the profile checklist, and with which chores -/// checked off. -/// -/// Every chore is read straight off the profile rather than from a local -/// dismissal flag, so a name, picture or fee that disappears across a profile -/// refresh puts the card back on its own. -struct ProfileTutorialState: Equatable { - - /// Withholds the card until a profile has loaded, so a session that has not - /// fetched one yet does not flash an all-incomplete checklist. - let hasProfile: Bool - let hasDisplayName: Bool - let hasProfilePicture: Bool - let hasMinimumTipAmount: Bool - - init(hasProfile: Bool, hasDisplayName: Bool, hasProfilePicture: Bool, hasMinimumTipAmount: Bool) { - self.hasProfile = hasProfile - self.hasDisplayName = hasDisplayName - self.hasProfilePicture = hasProfilePicture - self.hasMinimumTipAmount = hasMinimumTipAmount - } - - init(profile: Profile?) { - self.init( - hasProfile: profile != nil, - hasDisplayName: profile?.displayName?.isEmpty == false, - hasProfilePicture: profile?.profilePicture != nil, - hasMinimumTipAmount: profile?.minDmChatInitFee != nil - ) - } - - var items: [ProfileTutorialItem] { - [ - .displayName(isCompleted: hasDisplayName), - .profilePicture(isCompleted: hasProfilePicture), - .minimumTipAmount(isCompleted: hasMinimumTipAmount), - ] - } - - var isComplete: Bool { hasDisplayName && hasProfilePicture && hasMinimumTipAmount } - - var isVisible: Bool { hasProfile && !isComplete } -} diff --git a/Flipcash/Core/Screens/Main/You/TipCardActionButton.swift b/Flipcash/Core/Screens/Main/You/TipCardActionButton.swift deleted file mode 100644 index 1cfc49612..000000000 --- a/Flipcash/Core/Screens/Main/You/TipCardActionButton.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// TipCardActionButton.swift -// Flipcash -// - -import SwiftUI -import FlipcashUI - -/// One of the two large tip-card actions under the link row — Share or Download -/// (Figma node 9276:4756): a 28pt glyph over a dimmed caption, filling half the -/// row. -struct TipCardActionButton: View { - - let asset: Asset - let title: String - let action: VoidAction - - var body: some View { - Button(action: action) { - VStack(spacing: 6) { - Image.asset(asset) - .renderingMode(.template) - .frame(width: 28, height: 28) - .foregroundStyle(Color.textMain) - - Text(title) - .font(.appTextSmall) - .foregroundStyle(Color.textMain) - .opacity(0.5) - } - .frame(maxWidth: .infinity) - .frame(height: 88) - .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(title) - } -} diff --git a/Flipcash/Core/Screens/Main/You/TipCardExport.swift b/Flipcash/Core/Screens/Main/You/TipCardExport.swift index 84a52dcd2..63ea018c2 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 { "Profile Card" } return "\(stem).\(format.rawValue)" } diff --git a/Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift b/Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift deleted file mode 100644 index c5b0b7460..000000000 --- a/Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// TipCardLinkRow.swift -// Flipcash -// - -import SwiftUI -import UIKit -import FlipcashCore -import FlipcashUI - -/// The tip-card link row (Figma node 9276:4748): a chain-link glyph, the -/// shortened public link, and a copy button that flips to a checkmark for a -/// beat after copying. -/// -/// Owns the copy itself so the caller only supplies the URL. -struct TipCardLinkRow: View { - - let url: URL - - /// How long the copy button stays on the checkmark before reverting. - private static let confirmationDuration: Duration = .seconds(1.5) - - @State private var didCopy = false - - var body: some View { - Button(action: copy) { - HStack(spacing: 0) { - HStack(spacing: 6) { - Image.asset(.chainLink) - .renderingMode(.template) - .frame(width: 20, height: 20) - - Text(Self.displayText(for: url)) - .font(.appTextMedium) - .lineLimit(1) - // Elides only when the handle genuinely doesn't fit — - // no shrinking to dodge it, which would render the row - // at a smaller size than the ones around it. The forced - // clip in `displayText` is for the uuid form alone; a - // handle keeps every character it has room for and - // loses the tail only past that. On the narrowest - // supported screen (375pt) the budget is 257pt, which - // covered a 15-character handle of average width even - // under the four-character-longer `app.flipcash.com`; - // the apex host only widens that margin. - .truncationMode(.tail) - } - - Spacer(minLength: 8) - - Group { - if didCopy { - Image.system(.circleCheck) - .renderingMode(.template) - } else { - Image.asset(.squareBehindSquare) - .renderingMode(.template) - } - } - .frame(width: 20, height: 20) - } - .foregroundStyle(Color.textMain) - .opacity(0.7) - .padding(.horizontal, 12) - .frame(height: 40) - .frame(maxWidth: .infinity) - .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Copy tip card link") - .accessibilityIdentifier("you-copy-link-button") - } - - private func copy() { - UIPasteboard.general.string = url.absoluteString - withAnimation(.easeInOut(duration: 0.15)) { - didCopy = true - } - Task { - try? await Task.sleep(for: Self.confirmationDuration) - withAnimation(.easeInOut(duration: 0.15)) { - didCopy = false - } - } - } - - /// The link with its scheme dropped, and a uuid identifier clipped to a - /// stub so a full-width one doesn't push the row's copy button off the edge. - /// - /// A claimed handle is never clipped here. It is at most 15 characters — - /// well under the uuid that sits in the same place when there's no handle — - /// so it is handed to the layout whole and elides only if it truly overruns - /// the row. - static func displayText(for url: URL) -> String { - let stripped = url.absoluteString - .replacingOccurrences(of: "https://", with: "") - .replacingOccurrences(of: "http://", with: "") - - guard let slash = stripped.lastIndex(of: "/") else { return stripped } - - let identifier = stripped[stripped.index(after: slash)...] - guard identifier.count > identifierStubLength, Username(String(identifier)) == nil else { - return stripped - } - - return String(stripped[...slash]) + String(identifier.prefix(identifierStubLength)) + "…" - } - - /// Leading characters of the identifier kept in the shortened link. - private static let identifierStubLength = 5 -} 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 fd487c994..bb0651491 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -8,100 +8,38 @@ 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 -/// settings list — the tab-bar entry into My Account / Advanced. +/// The "You" tab: the user's own profile — cover, avatar, name, handle and bio — with the stats +/// card. /// -/// 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 rows push onto the tab's `.you` stack, so they never touch the v1 -/// scanner's Settings sheet. +/// The navigation bar carries the gear that pushes Settings; the action row beside the +/// avatar carries Edit Profile and Share, whose sheet shares the profile link, presents the tip card +/// full screen (`ProfileCardScreen`), or copies the link. 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 settings rows are this account's only -/// way to reach My Account, and with it Log Out. -/// -/// The two settings rows carry Android's own drawables (`MenuItems.kt`) rather -/// than SF Symbols: `person.text.rectangle` is already the Display Name row's -/// glyph one screen down, so the nearest system symbol would read as a repeat. +/// 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(BetaFlags.self) private var betaFlags - @Environment(TipCardPresentation.self) private var presentation + @Environment(RatesController.self) private var ratesController + @Environment(ToastController.self) private var toasts /// 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() - /// The version footer's beta-access easter egg — the tap count and the line - /// it shows for the last few taps. - @State private var versionUnlock = VersionTapUnlock() - @Environment(ToastController.self) private var toasts - @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. - 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) + @State private var isShowingShare = false + @State private var shareChoice: ProfileShareChoice? + @State private var isShowingCardDownload = false - /// 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 - - private let rowInsets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) - - /// The gap the page keeps between the version footer and the tab bar. + /// The gap the page keeps between its last row and the tab bar. private static let tabBarGap: CGFloat = 24 - /// How far below the safe area the top fade runs out. Matches `WalletScreen`. - private static let topFadeLength: CGFloat = 20 - /// Bottom inset for the scrolling content. Mirrors `WalletScreen`: the iOS 26 /// tab bar sits in the safe area, so the gap is the whole inset there; the /// legacy pill floats over the content and has to be cleared on top of it, @@ -115,131 +53,143 @@ 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) - } - .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 } + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + header + + if displayName == nil { + setupPrompt + .padding(.top, 24) + .padding(.horizontal, ProfileHeaderView.inset) + } else { + ProfileStatsCard( + minimumToChat: StartChattingFee.amount( + for: profile, + session: sessionContainer.session, + ratesController: ratesController + ), + joinedAt: profile?.joinedAt + ) + .padding(.top, 19) - if isExpanded { - closeButton - .transition(.opacity) - .opacity(1 - collapseProgress) + FeaturedGroupsSection(groups: sessionContainer.featuredGroups.groups) { + router.push(.tipConversation($0)) + } + .padding(.top, 20) + } } + .padding(.bottom, bottomContentInset) } - // 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) + // The banner runs under the status bar. + .ignoresSafeArea(edges: .top) + // The blur only belongs once the banner has scrolled up under the bar. + .hidesTopScrollEdge(untilOffset: ProfileCoverBanner.height / 2) } - .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { - TipCardDownloadSheet( - onSelect: { pendingDownload = $0; isShowingDownloadOptions = false }, - onCancel: { isShowingDownloadOptions = false } - ) + // The system bar carries the gear and, on iOS 26, the soft edge the banner scrolls under. + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + settingsGear + } + } + .overlay { + if router.isShowingProfileCard { + ProfileCardScreen(isShowingDownloadOptions: $isShowingCardDownload) + .transition(.asymmetric(insertion: .identity, removal: .opacity)) + } } .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) + } + .task(id: username) { + guard let username else { return } + await sessionContainer.featuredGroups.load(username: username) + } + // 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) + .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { + ProfileShareSheet(subtitle: shareSubtitle, offersCard: true) { shareChoice = $0 } + } } - // 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 — settings 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, + bannerControls: { EmptyView() }, + rowActions: { + ProfileEditCapsule { + router.push(.editProfile) + } + .accessibilityIdentifier("you-edit-profile") + + if displayName != nil { + shareButton + } + }, + underHandle: { + if shouldPromptForUsername { + Button(action: claimUsername) { + Text("Claim your username ›") + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .frame(minHeight: 22) } - .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { cardSlotFrame = $0 } + .buttonStyle(.plain) + .accessibilityIdentifier("you-claim-username") + } + } + ) + } + + private var shareButton: some View { + ProfileActionCircle(image: Image.asset(.shareOS)) { + isShowingShare = true + } + .accessibilityLabel("Share") + .accessibilityIdentifier("you-share") + } - // 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) + /// Settings, or Download while the profile card is up. One button so the glass + /// stays put and only the glyph changes. + private var settingsGear: some View { + let showsDownload = router.isShowingProfileCard + return Button { + if showsDownload { + isShowingCardDownload = true + } else { + router.push(.settings) } - .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 + } label: { + Image(systemName: "gearshape") + .opacity(showsDownload ? 0 : 1) + .overlay { + Image.asset(.fileDownload) + .opacity(showsDownload ? 1 : 0) + } } + .accessibilityLabel(showsDownload ? "Download" : "Settings") + .accessibilityIdentifier(showsDownload ? "you-download-button" : "you-settings") } - /// Stands in for the card until the profile has a display name: the card the + // 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. /// @@ -250,7 +200,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, @@ -298,7 +248,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) } } @@ -309,124 +259,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: - 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 settings rows - /// take up the slack. 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") - } - } - - settingsList - .padding(.top, displayName == nil && !profileTutorialState.isVisible ? 48 : 19) - - // v2 scrolls the version string with the content rather than - // pinning it above the tab bar (the v1 Settings sheet pinned it). - versionFooter - .padding(.top, 32) - } - } - - // MARK: - Settings list - - - private var settingsList: some View { - VStack(alignment: .leading, spacing: 0) { - SettingsRow(asset: .myAccount, title: "My Account", insets: rowInsets) { - router.push(.settingsMyAccount) - } - SettingsRow(asset: .advanced, title: "Advanced", insets: rowInsets) { - router.push(.settingsAdvancedFeatures) - } - } - .font(.appDisplayXS) - .foregroundStyle(Color.textMain) - } + /// 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, @@ -434,61 +280,10 @@ 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) } - ) - } - } - - private var versionFooter: some View { - Button { - let message = versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { - betaFlags.setAccessGranted(!betaFlags.accessGranted) - } - if let message { - // Two seconds and swapped in place, so consecutive taps read as one countdown. Without an action the toast - // lets taps through, so it never blocks the version string at the bottom of the list. - toasts.show( - .init(message, messageIdentifier: "you-version-toast", width: .fit, duration: .seconds(2)), - inPlace: true - ) - } - } label: { - Text("Version \(AppMeta.version) • Build \(AppMeta.build)") - .lineLimit(1) - .font(.appTextHeading) - .foregroundStyle(Color.textSecondary) - .padding(.vertical, 12) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier("you-version-footer") - } - // 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) } private var displayName: String? { guard let name = profile?.displayName, !name.isEmpty else { return nil } @@ -512,127 +307,15 @@ struct YouScreen: View { TipCode.Payload(userID: sessionContainer.session.userID).codeData() } - 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 + private var shareSubtitle: String? { + guard let displayName else { return nil } + return [displayName, username?.handle].compactMap { $0 }.joined(separator: " · ") } - /// 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) - } + private var url: URL { .tipcard(for: sessionContainer.session.userID, username: username) } // 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, @@ -642,49 +325,39 @@ 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 - } + /// Shows the card; it runs its own scan-style reveal, and the toolbar glyph fades to Download alongside. + private func showProfileCard() { + withAnimation(ProfileCardScreen.fade) { router.isShowingProfileCard = true } + } - 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) + private func handleShareChoice() { + defer { shareChoice = nil } + switch shareChoice { + case .share: shareTipCard() + case .showCard: showProfileCard() + case .copyLink: copyLink() + case nil: break } } - /// 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 copyLink() { + UIPasteboard.general.string = url.absoluteString + toasts.show(.init("Copied", systemImage: "checkmark.circle.fill", duration: .seconds(2))) } - private func handleProfileTutorialTap(_ item: ProfileTutorialItem) { - switch item { - case .displayName: - router.push(.changeDisplayName) - case .profilePicture: - router.push(.changeProfilePicture) - case .minimumTipAmount: - router.push(.setMinimumTip(isSetupStep: true)) + /// 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) } } - /// 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/ChatMuteStatusLabel.swift b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift index 240d5e337..63dec8c83 100644 --- a/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift +++ b/Flipcash/Core/Screens/Profile/ChatMuteStatusLabel.swift @@ -28,6 +28,9 @@ import FlipcashUI struct ChatMuteStatusLabel: View { let conversationID: ConversationID + /// Whether the label keeps its height while there is no mute. Off where the surrounding row + /// already fixes the height, so an empty label takes no space. + var reservesSpace = true @Environment(ConversationController.self) private var conversationController @@ -65,8 +68,10 @@ struct ChatMuteStatusLabel: View { // Holds the line's height whether or not there is a mute, so the rows below don't move // the moment one lapses or is cleared — that reads as the screen re-laying itself out // rather than one fact going away. Never drawn, never read aloud. - chipLabel("Muted") - .hidden() + if reservesSpace { + chipLabel("Muted") + .hidden() + } if let text { chipLabel(text) @@ -81,7 +86,7 @@ struct ChatMuteStatusLabel: View { .id(text) } } - .padding(.top, 3) + .padding(.top, reservesSpace ? 3 : 0) // Springs in and fades out. Arriving is the event worth seeing — the user just chose it — // so it gets the overshoot; going away is either their unmute or a deadline passing, and // neither wants drawing attention to. Read against the new state, so each direction picks @@ -103,10 +108,6 @@ struct ChatMuteStatusLabel: View { } private func chipLabel(_ string: String) -> some View { - HStack(spacing: 4) { - Image(systemName: "bell.slash") - Text(string) - } - .chip(.tinted(.warning, on: .warningSecondary)) + ProfileStatusChip(systemImage: "bell.slash", text: string, tint: .warning, fill: .warningSecondary) } } diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupNameScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupNameScreen.swift index 7e5cfa0a5..0da271350 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupNameScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupNameScreen.swift @@ -94,7 +94,7 @@ struct EditGroupNameScreen: View { // a display name; a bar title on top of it would ask twice. .navigationBarTitleDisplayMode(.inline) .dialog(item: $dialog) - .onAppear { isNameFocused = true } + .focusAfterPush($isNameFocused) // Leaving the screen abandons the submission: its only continuation is a pop off a stack // this screen no longer sits on. .onDisappear { submitTask?.cancel() } diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroups.swift b/Flipcash/Core/Screens/Profile/FeaturedGroups.swift new file mode 100644 index 000000000..503ab84d7 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/FeaturedGroups.swift @@ -0,0 +1,50 @@ +// +// FeaturedGroups.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore + +private let logger = Logger(label: "flipcash.featured-groups") + +/// The public groups the signed-in user features on their profile, in the order they chose. +/// +/// One list read by the You tab, Edit Profile and the picker, so a save shows everywhere at once. +/// Kept apart from `ConversationStore`: the server answers list-view metadata with no members or +/// viewer state, and featuring a group does not require being in it. +@MainActor +@Observable +final class FeaturedGroups { + + /// The most featured groups a profile can carry; the server rejects more. + static let limit = 10 + + private(set) var groups: [Conversation] = [] + + @ObservationIgnored private let fetching: (Username) async throws -> [Conversation] + + /// - Parameter fetching: reads the groups a username features. + init(fetching: @escaping (Username) async throws -> [Conversation]) { + self.fetching = fetching + } + + /// Re-reads the list for `username`, returning whether it did. A failure keeps the list already shown. + @discardableResult + func load(username: Username) async -> Bool { + do { + groups = try await fetching(username) + return true + } catch { + guard !Task.isCancelled else { return false } + logger.error("Failed to load featured groups", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to load featured groups") + return false + } + } + + /// Takes the list a save returned, which is the server's word on what is now featured. + func replace(with groups: [Conversation]) { + self.groups = groups + } +} diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift new file mode 100644 index 000000000..8131e9d89 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -0,0 +1,103 @@ +// +// FeaturedGroupsSection.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The "Favorite Public Groups" section on a profile, one row per group in the owner's order. Draws +/// nothing when the list is empty. +struct FeaturedGroupsSection: View { + + let groups: [Conversation] + let onTap: (ConversationID) -> Void + + var body: some View { + if !groups.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Color.rowSeparator + .frame(height: 1) + + Text("Favorite Public Groups") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .padding(.top, 24) + .padding(.bottom, 24) + .accessibilityAddTraits(.isHeader) + + VStack(spacing: 16) { + ForEach(groups) { group in + Button { onTap(group.id) } label: { + FeaturedGroupRow(group: group) { EmptyView() } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(group.featuredRowAccessibilityLabel)) + .accessibilityAddTraits(.isButton) + .accessibilityIdentifier("profile-featured-group") + } + } + } + .padding(.horizontal, ProfileHeaderView.inset) + .accessibilityIdentifier("profile-featured-groups") + } + } +} + +/// A public group as profiles and the picker show it: its picture, title, and its description or +/// head count. Never names members, which a viewer outside the group must not see. +struct FeaturedGroupRow: View { + + let group: Conversation + @ViewBuilder let trailing: Trailing + + @Environment(SessionContainer.self) private var sessionContainer + + private var subtitle: String { group.featuredRowSubtitle } + + var body: some View { + HStack(spacing: 14) { + ContactAvatarView( + id: group.id.description, + displayName: group.groupLinkTitle, + imageData: sessionContainer.profileAvatars.data(for: .chat(group.id)), + blurhash: group.picture?.thumbnailBlurhash, + size: 48, + cornerRadius: 15 + ) + VStack(alignment: .leading, spacing: 3) { + Text(group.groupLinkTitle) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + Text(subtitle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .lineLimit(2) + } + Spacer(minLength: 12) + trailing + } + .task(id: group.picture?.thumbnailBlobID) { + await sessionContainer.profileAvatars.load(.chat(group.id), picture: group.picture) + } + } +} + +extension Conversation { + /// The secondary line of a featured-group row: the group's description, or its member count when it has none. + var featuredRowSubtitle: String { + if let description = description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty { + return description + } + return rosterSummary.peopleCount + } + + /// The single VoiceOver label for a featured-group row: title, then subtitle. + var featuredRowAccessibilityLabel: String { + "\(groupLinkTitle), \(featuredRowSubtitle)" + } +} diff --git a/Flipcash/Core/Screens/Profile/PostPaymentChat.swift b/Flipcash/Core/Screens/Profile/PostPaymentChat.swift new file mode 100644 index 000000000..e3f1f9263 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/PostPaymentChat.swift @@ -0,0 +1,48 @@ +// +// PostPaymentChat.swift +// Flipcash +// + +import FlipcashCore + +/// Tracks the hand-off from paying to start a chat to opening that chat, which spans the sheet's +/// dismissal and the DM record arriving over the feed in either order. +nonisolated struct PostPaymentChat: Equatable { + + /// Set when the payment lands, consumed by the sheet's dismissal. + private(set) var didPay = false + + /// True from the sheet's dismissal until the DM's record arrives, so the push happens once and + /// only for a chat this screen just paid for. + private(set) var isAwaitingChat = false + + /// Records that the payment succeeded. + mutating func paymentSucceeded() { + didPay = true + } + + /// Consumes the success flag and returns the chat to push now, or `nil` when the record hasn't + /// landed yet (the push waits for it) or the screen is no longer in front (the push is dropped). + mutating func sheetDismissed(dmID: ConversationID?, isScreenInFront: Bool) -> ConversationID? { + guard didPay else { return nil } + didPay = false + guard isScreenInFront else { return nil } + guard let dmID else { + isAwaitingChat = true + return nil + } + return dmID + } + + /// Returns the chat to push when its record arrives while one is awaited. + mutating func dmArrived(_ dmID: ConversationID?, isScreenInFront: Bool) -> ConversationID? { + guard isAwaitingChat, let dmID else { return nil } + isAwaitingChat = false + return isScreenInFront ? dmID : nil + } + + /// Stops waiting for a record that never came. + mutating func gaveUp() { + isAwaitingChat = false + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift new file mode 100644 index 000000000..2f235ee98 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -0,0 +1,77 @@ +// +// ProfileCoverBanner.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// A profile's full-bleed cover: the picture once it has loaded, the blurhash while it does, and a +/// flat gray when there is no picture. +/// +/// Sized from the top of the screen, so the caller lets it run under the status bar. +struct ProfileCoverBanner: View { + + @Environment(SessionContainer.self) private var sessionContainer + + let userID: UserID + let coverPicture: ProfilePicture? + /// A picked image not uploaded yet, drawn in place of the stored cover. + var preview: UIImage? = nil + /// The banner's height; a profile's own cover is ``height``. + var bannerHeight: CGFloat = Self.height + @ViewBuilder let controls: () -> Controls + + static var height: CGFloat { 214 } + + var body: some View { + Color.clear + .frame(height: bannerHeight) + .frame(maxWidth: .infinity) + .overlay { cover } + .clipped() + .overlay(alignment: .top) { + controls() + } + .task(id: coverPicture?.blobID) { + await sessionContainer.profileAvatars.load(.cover(userID), picture: coverPicture) + } + // `.contain` keeps the banner's controls 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 preview { + fill(preview) + } else 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) { + fill(image) + } else if let preview = BlurHashCache.shared.image(for: coverPicture.thumbnailBlurhash) { + fill(preview) + } else { + Color.coverPlaceholder + } + } else { + Color.coverPlaceholder + } + } + + private func fill(_ image: UIImage) -> some View { + Image(uiImage: image) + .resizable() + .scaledToFill() + } +} + +extension ProfileCoverBanner where Controls == EmptyView { + + /// A cover with nothing laid over it. + init(userID: UserID, coverPicture: ProfilePicture?, preview: UIImage? = nil, bannerHeight: CGFloat = Self.height) { + self.init(userID: userID, coverPicture: coverPicture, preview: preview, bannerHeight: bannerHeight, controls: { EmptyView() }) + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift new file mode 100644 index 000000000..690618fc7 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -0,0 +1,144 @@ +// +// ProfileHeaderView.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// A profile's top block: a full-bleed cover, the avatar overlapping it, an action row, then name, +/// handle and bio, all left-aligned on a 24pt inset. +/// +/// The caller lets the view run under the status bar. 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? + @ViewBuilder let bannerControls: () -> BannerControls + @ViewBuilder let rowActions: () -> RowActions + @ViewBuilder let underHandle: () -> UnderHandle + + static var inset: CGFloat { 24 } + + static var avatarSize: CGFloat { 84 } + /// How far the avatar rises over the banner. + static var avatarOverlap: CGFloat { 42 } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ProfileCoverBanner( + userID: userID, + coverPicture: coverPicture, + controls: bannerControls + ) + + HStack(alignment: .top, spacing: 0) { + ContactAvatarView( + id: userID.uuidString, + displayName: displayName ?? "", + imageData: avatarData, + blurhash: avatarBlurhash, + size: Self.avatarSize + ) + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } + .padding(.top, -Self.avatarOverlap) + + Spacer(minLength: 8) + + HStack(spacing: 12) { + rowActions() + } + .padding(.top, 20) + } + .padding(.horizontal, Self.inset) + + if let displayName { + VStack(alignment: .leading, spacing: 0) { + Text(displayName) + .font(.appDisplaySmall) + .foregroundStyle(Color.textMain) + .accessibilityIdentifier("profile-name") + + if let handle { + Text(handle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .accessibilityIdentifier("profile-handle") + } + + underHandle() + + if let bio, !bio.isEmpty { + Text(bio) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 12) + .accessibilityIdentifier("profile-bio") + } + } + .padding(.top, 8) + .padding(.horizontal, Self.inset) + } + } + } +} + +/// The share button in the header's action row: an icon on a flat filled circle. +struct ProfileActionCircle: View { + + let image: Image + let action: () -> Void + + var body: some View { + Button(action: action) { + image + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .frame(width: ProfileActionButtonStyle.height, height: ProfileActionButtonStyle.height) + } + .buttonStyle(ProfileActionButtonStyle()) + } +} + +/// The header's "Edit Profile" button, a flat filled capsule beside ``ProfileActionCircle``. +struct ProfileEditCapsule: View { + + let action: () -> Void + + var body: some View { + Button(action: action) { + Text("Edit Profile") + .font(.appTextSmall) + .padding(.horizontal, 18) + } + .buttonStyle(ProfileActionButtonStyle()) + } +} + +/// The header buttons' chrome: a capsule filled at 10% of the action colour, one step above the +/// row tone of the stats card beneath them. +private struct ProfileActionButtonStyle: ButtonStyle { + + static var height: CGFloat { 38 } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(Color.textMain) + .frame(height: Self.height) + .background(Capsule().fill(Color.action.opacity(0.1))) + .contentShape(Capsule()) + .opacity(configuration.isPressed ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift new file mode 100644 index 000000000..d4c79ee3a --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileMenuItems.swift @@ -0,0 +1,53 @@ +// +// ProfileMenuItems.swift +// Flipcash +// + +/// An entry in another user's overflow menu. +nonisolated enum ProfileMenuItem: Equatable { + case mute + case report + case block + case unblock + + /// The entry's label. + var title: String { + switch self { + case .mute: return "Mute Notifications" + case .report: return "Report" + case .block: return "Block" + case .unblock: return "Unblock" + } + } + + /// The SF Symbol drawn beside the label. + var systemImage: String { + switch self { + case .mute: return "bell.slash" + case .report: return "flag" + case .block: return "nosign" + case .unblock: return "checkmark.circle" + } + } + + /// Whether the entry is drawn as a destructive action. Unblock restores something, so it isn't. + var isDestructive: Bool { + switch self { + case .report, .block: return true + case .mute, .unblock: return false + } + } +} + +/// The entries of another user's overflow menu. +nonisolated enum ProfileMenuItems { + + /// Mute needs a DM to silence and is hidden while blocked, since the DM is hidden too. + static func resolve(isBlocked: Bool, hasDM: Bool) -> [ProfileMenuItem] { + if isBlocked { return [.report, .unblock] } + var items: [ProfileMenuItem] = [] + if hasDM { items.append(.mute) } + items.append(contentsOf: [.report, .block]) + return items + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift b/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift new file mode 100644 index 000000000..452a34a66 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfilePinnedAction.swift @@ -0,0 +1,38 @@ +// +// ProfilePinnedAction.swift +// Flipcash +// + +import FlipcashCore + +/// The one button pinned under another user's profile, decided from who the viewer is looking at. +nonisolated enum ProfilePinnedAction: Equatable { + case none + case unblock + case openChat(ConversationID) + /// Pays `FiatAmount` to open the chat. + case startChatting(FiatAmount) + /// The fee isn't known yet, so the tap opens the amount screen instead of the confirmation sheet. + case startChattingUnpriced + + /// Blocked beats everything but the viewer's own profile: a blocked person's DM is hidden, and + /// opening or starting a chat with them would undo the block. + static func resolve(isSelf: Bool, isBlocked: Bool, dmID: ConversationID?, fee: FiatAmount?) -> ProfilePinnedAction { + if isSelf { return .none } + if isBlocked { return .unblock } + if let dmID { return .openChat(dmID) } + if let fee { return .startChatting(fee) } + return .startChattingUnpriced + } + + /// The button's label, or nil when nothing is pinned. + var title: String? { + switch self { + case .none: return nil + case .unblock: return "Unblock" + case .openChat: return "Open Chat" + case .startChatting(let fee): return "Send \(fee.formatted()) to Start Chatting" + case .startChattingUnpriced: return "Start Chatting" + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift new file mode 100644 index 000000000..f44bb7bbf --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileShareSheet.swift @@ -0,0 +1,85 @@ +// +// ProfileShareSheet.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// What a row of ``ProfileShareSheet`` asks for. +enum ProfileShareChoice { + case share + case showCard + case copyLink +} + +/// The bottom sheet behind a profile's Share button: share the link, show the profile card, or copy the link. +/// +/// A row dismisses the sheet and reports its choice through `onChoose`; the presenter acts on it from +/// the sheet's `onDismiss`, so a share sheet or cover never presents over a sheet that is leaving. +/// `offersCard` false drops the card row, for a profile that has no card to show. +struct ProfileShareSheet: View { + + let subtitle: String? + let offersCard: Bool + let onChoose: (ProfileShareChoice) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + PartialSheet(background: .backgroundMain) { + VStack(spacing: 20) { + header + + if let subtitle { + Text(subtitle) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + // Same floor as ``MuteChatSheet``'s caption: `PartialSheet` can propose less + // height than the text needs while it corrects to its measured size. + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + } + + VStack(spacing: 12) { + row(icon: Image.asset(.shareOS), title: "Share Profile", identifier: "profile-share-row", choice: .share) + if offersCard { + row(icon: Image(systemName: "person.crop.rectangle"), title: "Show Profile Card", identifier: "profile-card-row", choice: .showCard) + } + row(icon: Image.asset(.chainLink), title: "Copy Link", identifier: "profile-copy-link-row", choice: .copyLink) + } + } + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, bottomPadding) + } + } + + /// Centred title with a close button, the same title bar as ``MuteChatSheet``. + private var header: some View { + ZStack { + Text("Share User Profile") + .font(.appBarButton) + .foregroundStyle(Color.textMain) + + HStack { + Spacer() + CloseButton(style: .glass) { dismiss() } + } + } + } + + /// 16 under the last row, less what the sheet already holds back for the home indicator. + private var bottomPadding: CGFloat { + let reserved = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + return max(0, 16 - reserved) + } + + private func row(icon: Image, title: String, identifier: String, choice: ProfileShareChoice) -> some View { + ChatActionRow(icon: icon, title: title, accessibilityIdentifier: identifier) { + onChoose(choice) + dismiss() + } + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift new file mode 100644 index 000000000..85668f10b --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileStatsCard.swift @@ -0,0 +1,58 @@ +// +// 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() ?? "—", + valueFont: .appTextLarge, + identifier: "profile-stat-minimum" + ) + .frame(width: 176) + + Color.rowSeparator + .frame(width: 1, height: 48) + + stat( + title: "Date Joined", + value: joinedAt?.formatted(.dateTime.month(.wide).year()) ?? "—", + valueFont: .appTextLarge, + identifier: "profile-stat-joined" + ) + .frame(maxWidth: .infinity) + } + .frame(height: 84) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .padding(.horizontal, ProfileHeaderView.inset) + } + + private func stat(title: String, value: String, valueFont: Font, identifier: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + Text(value) + .font(valueFont) + .foregroundStyle(Color.textMain) + } + .padding(.leading, 16) + .padding(.top, 16) + // Top-aligned so both labels share a baseline even though the values differ in size. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .combine) + .accessibilityIdentifier(identifier) + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileStatusChip.swift b/Flipcash/Core/Screens/Profile/ProfileStatusChip.swift new file mode 100644 index 000000000..0ead93e56 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ProfileStatusChip.swift @@ -0,0 +1,24 @@ +// +// ProfileStatusChip.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// A tinted capsule stating one setting of the viewer's over a profile, such as muted or blocked. +struct ProfileStatusChip: View { + + let systemImage: String + let text: String + let tint: Color + let fill: Color + + var body: some View { + HStack(spacing: 4) { + Image(systemName: systemImage) + Text(text) + } + .chip(.tinted(tint, on: fill)) + } +} 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/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 907dbd597..6beaae56a 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -6,15 +6,15 @@ // import SwiftUI +import UIKit import FlipcashCore import FlipcashUI -/// The counterpart's Flipcash profile, carrying the actions the viewer has over the person rather -/// than over one chat: muting the DM with them, and blocking them outright. +/// Another user's Flipcash profile: the shared header and stats, a ⋯ menu carrying the actions the +/// viewer has over the person, and one pinned button that starts, opens, or restores the chat. /// /// Reached from a tip DM's title, from a face in a group transcript, and from a person link. All land -/// here because all are the same question — who is this — so Block works on the person either way. The mute row -/// silences the DM with them, so it shows only when the profile was opened from that DM. +/// here because all are the same question — who is this — so Block works on the person either way. struct UserProfileScreen: View { let userID: UserID let origin: UserProfileOrigin @@ -26,15 +26,7 @@ struct UserProfileScreen: View { var body: some View { let seed = sessionContainer.conversationController.counterpartSeed(forUserID: userID) UserProfileContent( - // Nil until a tip creates the chat server-side, and re-read on every pass, so a DM that - // appears while the screen is open brings its mute row with it. - conversationID: origin.showsMute - ? sessionContainer.conversationController.tipDM(withUserID: userID)?.id - : nil, - showsChatActions: origin.showsChatActions( - profileUserID: userID, - selfUserID: sessionContainer.conversationController.selfUserID - ), + origin: origin, model: UserProfileViewModel( userID: userID, flipClient: sessionContainer.flipClient, @@ -52,154 +44,308 @@ struct UserProfileScreen: View { } private struct UserProfileContent: View { - /// The DM to mute, or nil when there is no chat with this person yet or the profile wasn't - /// opened from it. - let conversationID: ConversationID? - /// Whether to offer Message and Send Cash — see ``UserProfileOrigin/showsChatActions(profileUserID:selfUserID:)``. - let showsChatActions: Bool @Environment(AppRouter.self) private var router + @Environment(SessionContainer.self) private var sessionContainer @Environment(RatesController.self) private var ratesController @Environment(ConversationController.self) private var conversationController + @Environment(BlocklistController.self) private var blocklistController + @Environment(ToastController.self) private var toasts + + let origin: UserProfileOrigin + @State private var isShowingShare = false + @State private var shareChoice: ProfileShareChoice? @State private var model: UserProfileViewModel @State private var dialogItem: DialogItem? - - init(conversationID: ConversationID?, showsChatActions: Bool, model: UserProfileViewModel) { - self.conversationID = conversationID - self.showsChatActions = showsChatActions + @State private var isPickingMuteDuration = false + @State private var isReporting = false + @State private var startChattingRequest: StartChattingRequest? + @State private var postPayment = PostPaymentChat() + /// Where this screen sits in its stack, recorded when it first appears. + @State private var position: AppRouter.StackPosition? + + init(origin: UserProfileOrigin, model: UserProfileViewModel) { + self.origin = origin _model = State(initialValue: model) } + /// What the start-chatting sheet is opened for, snapshotted at the tap. + private struct StartChattingRequest: Identifiable { + let target: SendTarget + let fee: FiatAmount + + var id: SendTarget { target } + } + + // MARK: - Derived state - + + private var session: Session { sessionContainer.session } + + private var isSelf: Bool { model.userID == session.userID } + + private var isBlocked: Bool { blocklistController.isBlocked(model.userID) } + + private var dmID: ConversationID? { conversationController.tipDMID(withUserID: model.userID) } + + private var fee: FiatAmount? { + let currency = ratesController.balanceCurrency + return StartChattingFee.amount( + recipientFee: model.minDmChatInitFee, + presets: session.userFlags?.tipPresets(for: currency), + currency: currency, + rates: ratesController.cachedRates + ) + } + + private var pinnedAction: ProfilePinnedAction { + ProfilePinnedAction.resolve(isSelf: isSelf, isBlocked: isBlocked, dmID: dmID, fee: fee) + } + + private var menuItems: [ProfileMenuItem] { + guard !isSelf else { return [] } + return ProfileMenuItems.resolve(isBlocked: isBlocked, hasDM: dmID != nil) + } + + /// Whether this profile is the visible top of its stack, with its tab or sheet active. + private var isScreenInFront: Bool { + guard let position else { return false } + return router.isTopmost(position) + } + + private var sendTarget: SendTarget { + .tip(TipRecipient(userID: model.userID, displayName: model.displayName, username: model.username, origin: .tipcard)) + } + + // MARK: - Body - + var body: some View { Background(color: .backgroundMain) { - VStack(spacing: 16) { - ContactAvatarView( - id: model.userID.uuidString, - displayName: model.displayName, - imageData: model.imageData, - blurhash: model.blurhash, - size: 88 - ) - .padding(.top, 40) - + ScrollView { VStack(spacing: 0) { - // The handle and join date read as a block under the name, so - // they group tighter than the screen's other spacing (node - // 9443:8928). - VStack(spacing: 5) { - Text(model.displayName) - .font(.appDisplaySmall) - .foregroundStyle(.textMain) - - if let handle = model.handle { - Text(handle) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - } - - if let joined = model.joinedText { - Text(joined) - .font(.appTextSmall) - .foregroundStyle(.textSecondary) - } + ProfileHeaderView( + userID: model.userID, + displayName: model.displayName, + handle: model.handle, + bio: model.bio, + avatarData: model.imageData, + avatarBlurhash: model.blurhash, + coverPicture: model.coverPicture, + bannerControls: { EmptyView() }, + rowActions: { + statusBadges + shareButton + }, + underHandle: { EmptyView() } + ) + + ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt) + .padding(.top, 20) + + FeaturedGroupsSection(groups: model.featuredGroups) { + router.push(.tipConversation($0)) } + .padding(.top, 20) + } + .padding(.bottom, 24) + } + // The banner runs under the status bar. + .ignoresSafeArea(edges: .top) + // The blur only belongs once the banner has scrolled up under the bar. + .hidesTopScrollEdge(untilOffset: ProfileCoverBanner.height / 2) + } + // On iOS 26 the pinned button joins the bottom scroll edge effect, so content fades under it. + .scrollEdgeBar(.bottom) { + pinnedButton + // Toasts rise above the button rather than covering it. + .toastClearance(toasts) + } + // The system bar carries back and the overflow menu, and the soft edge the banner scrolls under. + .toolbar { + if !menuItems.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + overflowMenu + } + } + } + .sheet(isPresented: $isShowingShare, onDismiss: handleShareChoice) { + ProfileShareSheet(subtitle: shareSubtitle, offersCard: false) { shareChoice = $0 } + } + .dialog(item: $dialogItem) + .sheet(isPresented: $isPickingMuteDuration) { + if let dmID { + MuteChatSheet(conversationID: dmID, isPresented: $isPickingMuteDuration) + } + } + .fullScreenCover(isPresented: $isReporting) { + NavigationStack { + ReportFlowScreen(target: .user(model.userID)) + } + } + .sheet(item: $startChattingRequest, onDismiss: { + if let id = postPayment.sheetDismissed(dmID: dmID, isScreenInFront: isScreenInFront) { + router.push(.tipConversation(id)) + } + }) { request in + StartChattingSheet(target: request.target, fee: request.fee) { + postPayment.paymentSucceeded() + } + } + .onChange(of: dmID) { _, id in + if let id = postPayment.dmArrived(id, isScreenInFront: isScreenInFront) { + router.push(.tipConversation(id)) + } + } + // The record normally lands within a moment; past this the profile stays put and its + // button has already flipped to Open Chat. + .task(id: postPayment.isAwaitingChat) { + guard postPayment.isAwaitingChat else { return } + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled else { return } + postPayment.gaveUp() + } + .onAppear { + if position == nil { position = router.positionOfTopmost() } + } + .task { + await model.loadProfile() + await model.loadFeaturedGroups() + } + } - // Centered between the join date and the first row's text, 25pt each side; - // the row's own top inset supplies the lower 25. - HStack(spacing: 0) { - if showsChatActions { - ProfileActionButton(title: "Message") { - Image(systemName: "bubble.left.fill") - .font(.appTextLarge) - } action: { - router.push(.tipConversationForUser(model.userID)) - } - .accessibilityIdentifier("profile-message") - - // Hidden for now; the destination and `startSendCash` stay wired, so - // bringing it back is uncommenting this. - // ProfileActionButton(title: "Send Cash") { - // // The chat's collapsed Send Cash style, so € and ¥ read the same here. - // Text(ratesController.balanceCurrency.compactSymbol) - // .font(.appTextXL) - // } action: { - // router.push(.tipConversationForUserSendingCash(model.userID)) - // } - // .accessibilityIdentifier("profile-send-cash") - } - - ProfileActionButton(title: "Share") { - Image.asset(.shareOS) - .renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - } action: { - model.share() - } - .accessibilityIdentifier("profile-share") - } - .padding(.top, 25) + // MARK: - Status badges - - // Under the actions rather than the name, so the actions sit at the same height on - // every profile; only a DM's profile, the one with a mute, holds the chip's line. - if let conversationID { - ChatMuteStatusLabel(conversationID: conversationID) - .padding(.top, 16) - } + /// Blocked and muted are separate settings, so each gets its own badge. They sit in the action + /// row, whose height the share button sets, so one coming or going never moves the bio below. + private var statusBadges: some View { + HStack(spacing: 8) { + if isBlocked { + ProfileStatusChip(systemImage: "nosign", text: "Blocked", tint: .warning, fill: .warningSecondary) + .accessibilityIdentifier("profile-blocked-badge") + } + if let dmID { + ChatMuteStatusLabel(conversationID: dmID, reservesSpace: false) + } + } + } - VStack(spacing: 0) { - // Mute, then report, then block: the reversible and routine first, then the - // one that asks someone else to look, then the one that ends the relationship. - // Same shape as a group's profile, where leaving holds the last place. - if let conversationID { - ChatMuteRow(conversationID: conversationID, insets: rowInsets) - } - - ReportRow(target: .user(model.userID), insets: rowInsets) - - Row(insets: rowInsets) { - Image(systemName: "nosign") - .frame(minWidth: 45) - Text("Block") - .foregroundStyle(.textMain) - Spacer() - } action: { - dialogItem = blockDialog() - } - .accessibilityIdentifier("chat-block") - } - .font(.appDisplayXS) + // MARK: - Banner controls - + + private var overflowMenu: some View { + Menu { + ForEach(menuItems, id: \.title) { item in + Button(role: item.isDestructive ? .destructive : nil) { + perform(item) + } label: { + Label(item.title, systemImage: item.systemImage) + } + } + } label: { + Image.system(.ellipsis) + .renderingMode(.template) + } + .accessibilityLabel("More") + .accessibilityIdentifier("profile-overflow") + } + + private var shareButton: some View { + ProfileActionCircle(image: Image.asset(.shareOS)) { + isShowingShare = true + } + .accessibilityLabel("Share profile") + .accessibilityIdentifier("profile-share") + } + + private func handleShareChoice() { + defer { shareChoice = nil } + switch shareChoice { + case .share: + model.share() + case .copyLink: + UIPasteboard.general.string = model.shareURL.absoluteString + toasts.show(.init("Copied", systemImage: "checkmark.circle.fill", duration: .seconds(2))) + case .showCard, nil: + break + } + } + + private var shareSubtitle: String? { + [model.displayName, model.handle].compactMap { $0 }.joined(separator: " · ") + } + + private func perform(_ item: ProfileMenuItem) { + switch item { + case .mute: + isPickingMuteDuration = true + case .report: + isReporting = true + case .block: + dialogItem = blockDialog() + case .unblock: + Task { await model.unblock() } + } + } + + // MARK: - Pinned action - + + @ViewBuilder + private var pinnedButton: some View { + if let title = pinnedAction.title { + VStack(spacing: 8) { + if showsE2eeFooter { + E2eeFooter(kind: .dm) } - Spacer() + CodeButton(style: .filled, title: title, action: tapPinned) + .accessibilityIdentifier("profile-pinned-action") } - .padding(.horizontal, 20) + .padding(.horizontal, ProfileHeaderView.inset) + .padding(.top, 12) + .padding(.bottom, 8) + } else if showsE2eeFooter { + E2eeFooter(kind: .dm) } - .safeAreaInset(edge: .bottom) { - if showsE2eeFooter { - E2eeFooter(kind: .dm) + } + + private func tapPinned() { + switch pinnedAction { + case .none: + break + case .unblock: + Task { await model.unblock() } + case .openChat(let id): + if origin.returnsToExistingDM { + router.popTopmost() + } else { + router.push(.tipConversation(id)) } + case .startChattingUnpriced: + guard passesGiveCashGate() else { return } + router.presentSendAmount(sendTarget) + case .startChatting(let fee): + guard passesGiveCashGate() else { return } + startChattingRequest = StartChattingRequest(target: sendTarget, fee: fee) } - .navigationTitle("") - .toolbarTitleDisplayMode(.inline) - .dialog(item: $dialogItem) - .task { await model.loadProfile() } + } + + /// Shows the blocking dialog and returns false when the viewer can't pay yet. + private func passesGiveCashGate() -> Bool { + let rate = ratesController.rateForBalanceCurrency() + guard let dialog = giveCashGate(session: session, rate: rate).blockingDialog(router: router, addMoneySource: .chat, context: .sendTips) else { + return true + } + session.dialogItem = dialog + return false } /// Only a DM that will actually be encrypted claims to be — see ``E2eePolicy``. private var showsE2eeFooter: Bool { - guard let conversationID, let conversation = conversationController.conversation(withID: conversationID) else { + guard let dmID, let conversation = conversationController.conversation(withID: dmID) else { return false } return E2eePolicy.shouldEncrypt(conversation) } - private var rowInsets: EdgeInsets { - .init(top: 25, leading: 0, bottom: 25, trailing: 0) - } - private func blockDialog() -> DialogItem { .alert( title: "Block \(model.displayName)?", @@ -223,42 +369,47 @@ nonisolated enum UserProfileOrigin: Hashable { case mention /// A `flipcash.com/` or `flipcash.com/` link opened into the app. case deeplink - - /// Whether the profile offers Message and Send Cash: not from the DM they would lead back - /// into, and never on the viewer's own profile. - func showsChatActions(profileUserID: UserID, selfUserID: UserID) -> Bool { - guard profileUserID != selfUserID else { return false } + /// Their code scanned with the camera, or their profile QR link opened, after the card shows. + case scan + /// A username search from New Chat, with no DM yet. + case usernameLookup + /// A transaction's details, with no DM yet. + case transaction + + /// Whether Open Chat returns to the DM the profile was opened from rather than pushing a + /// second copy of it. + var returnsToExistingDM: Bool { switch self { - case .directMessage: return false - case .groupMember, .mention, .deeplink: return true - } - } - - /// Whether the profile offers muting the DM with this person: only from that DM, since from a - /// group the row would read as muting the group. - var showsMute: Bool { - switch self { - case .directMessage: return true - case .groupMember, .mention, .deeplink: return false + case .directMessage: + return true + case .groupMember, .mention, .deeplink, .scan, .usernameLookup, .transaction: + return false } } /// Whether the profile was fetched and cached just before the screen opened, so the screen - /// reads the cache instead of fetching again. A link is looked up before it navigates. + /// reads the cache instead of fetching again. A link, a scan, and a username search all look the + /// person up before they navigate. var arrivesFetched: Bool { switch self { - case .deeplink: return true - case .directMessage, .groupMember, .mention: return false + case .deeplink, .scan, .usernameLookup: + return true + case .directMessage, .groupMember, .mention, .transaction: + return false } } /// Whether blocking closes just the profile rather than resetting its stack. From a chat the /// stack beneath can hold the blocked person's DM, so it resets; a link opened the profile over - /// whatever the user was on, and that is where blocking returns them. + /// whatever the user was on, and that is where blocking returns them, as does a transaction's + /// details. A scan or a username search opens the profile as the Chats tab's only entry, so a + /// reset lands on the chat list either way. var blockReturnsToOpener: Bool { switch self { - case .deeplink: return true - case .directMessage, .groupMember, .mention: return false + case .deeplink, .transaction: + return true + case .directMessage, .groupMember, .mention, .scan, .usernameLookup: + return false } } } @@ -273,7 +424,13 @@ final class UserProfileViewModel { private(set) var username: Username? private(set) var blurhash: String? - private(set) var joinedText: String? + private(set) var bio: String? + private(set) var coverPicture: ProfilePicture? + private(set) var customization: TipCardCustomization? + private(set) var joinedAt: Date? + private(set) var minDmChatInitFee: FiatAmount? + /// The public groups this person features, in their order. + private(set) var featuredGroups: [Conversation] = [] /// What to call this person: their name when they have one, their handle /// when they don't. A handle is public and stable, so it beats the generic @@ -338,21 +495,48 @@ final class UserProfileViewModel { await profileAvatars.load(userID: userID, picture: profile.profilePicture) } + /// Reads the groups this person features, once their handle is known. A failure leaves the + /// section hidden; the rest of the profile stands. + func loadFeaturedGroups() async { + guard let username else { return } + do { + featuredGroups = try await flipClient.getFeaturedGroups(owner: owner, username: username) + } catch { + guard !Task.isCancelled else { return } + ErrorReporting.captureError(error, reason: "Failed to load featured groups") + } + } + private func apply(_ profile: Profile) { if let name = profile.displayName, !name.isEmpty { self.name = name } if let username = profile.username { self.username = username } if blurhash == nil { blurhash = profile.profilePicture?.thumbnailBlurhash } - if let joined = profile.joinedLine { - joinedText = joined - } + bio = profile.bio + coverPicture = profile.coverPicture + customization = profile.tipCardCustomization + if let joinedAt = profile.joinedAt { self.joinedAt = joinedAt } + minDmChatInitFee = profile.minDmChatInitFee } + /// This person's public link, the one their own You tab shares. + var shareURL: URL { .tipcard(for: userID, username: username) } + /// Opens the share sheet on this person's public link, the one their own You tab shares. func share() { - let item = TipCodeShareItem.profile(url: .tipcard(for: userID, username: username), displayName: name) + let item = TipCodeShareItem.profile(url: shareURL, displayName: name) ShareSheet.present(activityItem: item) { _ in } } + /// Lifts the block; the badge and pinned button follow the blocklist. A failure says so. + func unblock() async { + do { + try await blocklistController.unblock(userID: userID) + } catch { + session.dialogItem = .error(title: "Something Went Wrong", subtitle: "We were unable to unblock the user. Please try again") + ErrorReporting.captureError(error, reason: "Failed to unblock user") + } + } + /// Blocks the user and closes the profile — see ``UserProfileOrigin/blockReturnsToOpener``. The /// blocklist reconcile hides the conversation. func block() async { diff --git a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift index ee0350e51..6bfef51b7 100644 --- a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift +++ b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift @@ -75,8 +75,7 @@ final class SendAmountViewModel { /// False for a contact send and for a thread that already exists. var opensTipDM: Bool { guard case .tip(let recipient) = target else { return false } - // The same rule `ConversationScreen.chatExists` draws: a tip DM's id is - // derived locally, so the feed holding it is what says the chat is real. + // A tip DM's id is derived locally, so the feed holding it is what says the chat is real. return conversationController.conversation( withID: .tipDm(between: session.userID, and: recipient.userID) ) == nil diff --git a/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift b/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift new file mode 100644 index 000000000..aed045bbd --- /dev/null +++ b/Flipcash/Core/Screens/Settings/AccountInfoDetails.swift @@ -0,0 +1,63 @@ +// +// AccountInfoDetails.swift +// Flipcash +// + +import Foundation +import FlipcashCore + +/// The rows on the Account Info screen, built from the account's identity. +/// +/// Named `AccountInfoDetails` because `AccountInfo` is already the on-chain +/// token-account model in FlipcashCore. +nonisolated struct AccountInfoDetails { + + /// One copyable line on Account Info: what the row shows, and what a tap + /// puts on the clipboard when the two differ. + struct Row: Hashable { + let title: String + let display: String + let copyValue: String + let accessibilityID: String + } + + /// Phone and email only when the profile has them, then the account ID and + /// the owner authority's public key. + static func rows(profile: Profile?, userID: UserID, authority: PublicKey) -> [Row] { + var rows: [Row] = [] + + if let phone = profile?.phone { + rows.append(Row( + title: "Phone", + display: phone.national, + copyValue: phone.e164, + accessibilityID: "account-info-phone" + )) + } + + if let email = profile?.email, !email.isEmpty { + rows.append(Row( + title: "Email", + display: email, + copyValue: email, + accessibilityID: "account-info-email" + )) + } + + rows.append(Row( + title: "Account ID", + display: userID.uuidString, + copyValue: userID.uuidString, + accessibilityID: "account-info-account-id" + )) + + rows.append(Row( + title: "Public Key", + display: authority.base58, + copyValue: authority.base58, + accessibilityID: "account-info-public-key" + )) + + return rows + } +} diff --git a/Flipcash/Core/Screens/Settings/AccountInfoScreen.swift b/Flipcash/Core/Screens/Settings/AccountInfoScreen.swift new file mode 100644 index 000000000..528933184 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/AccountInfoScreen.swift @@ -0,0 +1,61 @@ +// +// AccountInfoScreen.swift +// Flipcash +// + +import SwiftUI +import UIKit +import FlipcashUI +import FlipcashCore + +/// The account's phone, email, user ID and owner public key. Tapping a row +/// copies its value. +struct AccountInfoScreen: View { + + @Environment(SessionContainer.self) private var sessionContainer + @Environment(ToastController.self) private var toasts + + var body: some View { + let session = sessionContainer.session + let rows = AccountInfoDetails.rows( + profile: session.profile, + userID: session.userID, + authority: session.owner.authorityPublicKey + ) + + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(rows, id: \.self) { row in + Button { + copy(row) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(row.title) + .font(.appTextHeading) + .foregroundStyle(Color.textSecondary) + Text(row.display) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(row.accessibilityID) + } + } + } + .padding(.horizontal, 20) + } + .navigationTitle("Account Info") + .toolbarTitleDisplayMode(.inline) + } + + private func copy(_ row: AccountInfoDetails.Row) { + UIPasteboard.general.string = row.copyValue + toasts.show(.init("Copied", systemImage: "checkmark.circle.fill", duration: .seconds(2))) + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift index 56c3893e7..acdae54d4 100644 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsAdvancedBetaFeaturesScreen.swift @@ -53,14 +53,14 @@ struct SettingsAdvancedBetaFeaturesScreen: View { if betaFlags.accessGranted { if !developerOptions.isEmpty { - sectionHeader("Developer") + SettingsSectionHeader("Developer") ForEach(developerOptions) { option in BetaFlagToggleRow(option: option, isOn: betaFlags.bindingFor(option: option)) } } - sectionHeader("Account") + SettingsSectionHeader("Account") unlinkRow(title: "Unlink Email", isDisabled: session.profile?.email == nil) { isConfirmingUnlinkEmail = true @@ -69,7 +69,7 @@ struct SettingsAdvancedBetaFeaturesScreen: View { isConfirmingUnlinkPhone = true } - sectionHeader("Contracts") + SettingsSectionHeader("Contracts") ForEach(ContractInfo.all) { info in ContractInfoRow(info: info) @@ -121,18 +121,6 @@ struct SettingsAdvancedBetaFeaturesScreen: View { // MARK: - Subviews - - private func sectionHeader(_ title: String) -> some View { - HStack { - Text(title) - .font(.appTextHeading) - .foregroundStyle(.textSecondary) - Spacer() - } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 8) - } - private func unlinkRow(title: String, isDisabled: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { diff --git a/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift b/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift deleted file mode 100644 index c8242ed50..000000000 --- a/Flipcash/Core/Screens/Settings/SettingsAdvancedFeaturesScreen.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// SettingsAdvancedFeaturesScreen.swift -// Flipcash -// -// Created by Raul Riera on 2026-04-27. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -/// The Advanced settings list (Figma node 9279:121978): the account-level -/// actions — Access Key, Switch Accounts, Log Out, Delete Account — alongside -/// Beta Features and Application Logs. My Account keeps the rows about the -/// profile this account presents, and who it will deal with. -/// -/// The row icons track Android's (`AdvancedFeatureMenuItems.kt`) through the -/// nearest SF Symbol. -struct SettingsAdvancedFeaturesScreen: View { - - @Environment(AppRouter.self) private var router - @Environment(BetaFlags.self) private var betaFlags - @Environment(SessionAuthenticator.self) private var sessionAuthenticator - @Environment(ContactSyncController.self) private var contactSyncController - - @State private var dialogItem: DialogItem? - - private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) - - var body: some View { - Background(color: .backgroundMain) { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - SettingsRow(systemImage: "key.horizontal", title: "Access Key", insets: insets) { - dialogItem = .alert( - title: "View Your Access Key?", - subtitle: "Your Access Key will grant access to your Flipcash account. Keep it private and safe" - ) { - DialogAction.destructive("View Access Key") { - router.push(.accessKey) - } - DialogAction.cancel() - } - } - - SettingsRow(systemImage: "flask", title: "Beta Features", insets: insets) { - router.push(.settingsAdvancedBetaFeatures) - } - - SettingsRow(systemImage: "doc.text", title: "Application Logs", insets: insets) { - router.push(.settingsApplicationLogs) - } - - // Sits with the other beta tool rather than on My Account: it is - // a way out of this account, next to Log Out, not a detail of it. - if betaFlags.canSwitchAccounts { - SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: .beta, insets: insets) { - router.push(.settingsAccountSelection) - } - .accessibilityIdentifier("account-switch-accounts-row") - } - - SettingsRow(asset: .logout, title: "Log Out", insets: insets) { - dialogItem = .alert( - title: "Are You Sure You Want To Log Out?", - subtitle: "You can get into this account using your Access Key" - ) { - DialogAction.destructive("Log Out") { - logout() - } - DialogAction.cancel() - } - } - - SettingsRow(asset: .delete, title: "Delete Account", insets: insets) { - dialogItem = .alert( - title: "Permanently Delete Account?", - subtitle: "This will permanently delete your Flipcash account" - ) { - DialogAction.destructive("Permanently Delete Account") { - deleteAccount() - } - DialogAction.cancel() - } - } - } - .font(.appDisplayXS) - .foregroundStyle(.textMain) - .padding(.horizontal, 20) - } - } - .navigationTitle("Advanced") - .toolbarTitleDisplayMode(.inline) - .dialog(item: $dialogItem) - } - - // MARK: - Actions - - - private func deleteAccount() { - Task { - router.dismissSheet() - try await Task.delay(milliseconds: 250) - // No server-side account deletion exists; logout only tears down - // locally. Wipe the server's stored contact set first so it isn't - // retained after the account is "deleted". Best-effort — must run - // before logout while the session can still authenticate the call. - await contactSyncController.clearServerContactSetForAccountDeletion() - sessionAuthenticator.logout() - } - } - - private func logout() { - Task { - router.dismissSheet() - try await Task.delay(milliseconds: 250) - sessionAuthenticator.logout() - } - } -} diff --git a/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift b/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift deleted file mode 100644 index 78e9b44ba..000000000 --- a/Flipcash/Core/Screens/Settings/SettingsMyAccountScreen.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// SettingsMyAccountScreen.swift -// Flipcash -// -// Created by Raul Riera on 2026-04-27. -// - -import SwiftUI -import FlipcashUI -import FlipcashCore - -/// The My Account settings list (Figma node 9544:18478): who this account -/// deals with, and the profile it presents. The account-level actions — Access -/// Key, Switch Accounts, Log Out, Delete Account — live on Advanced. -/// -/// The design also lists Require Biometrics, which isn't here: iOS has no -/// biometrics setting to toggle. -/// -/// The row icons track Android's (`MyAccountMenuItems.kt`) through the nearest -/// SF Symbol, so the two platforms read alike without importing Material into -/// a system-symbol set. Profile Picture is the exception: `familiar_face_and_zone` -/// has no SF equivalent, so it ships as an asset. -struct SettingsMyAccountScreen: View { - - @Environment(AppRouter.self) private var router - @Environment(Session.self) private var session - - private let insets = EdgeInsets(top: 25, leading: 0, bottom: 25, trailing: 0) - - var body: some View { - Background(color: .backgroundMain) { - ScrollView(showsIndicators: false) { - list() - } - .padding(.horizontal, 20) - } - .navigationTitle("My Account") - .toolbarTitleDisplayMode(.inline) - } - - @ViewBuilder - private func list() -> some View { - VStack(alignment: .leading, spacing: 0) { - SettingsRow(systemImage: "person.text.rectangle", title: "Display Name", insets: insets) { - router.push(.changeDisplayName) - } - - // No balance gate here: the gate exists to stop squatting at claim time. A user who - // already holds a handle has cleared it, and re-gating a change would hold their - // handle hostage to a balance that has since moved. - if let username = session.profile?.username { - SettingsRow(systemImage: "at", title: "Username", insets: insets) { - router.push(.username(username)) - } - .accessibilityIdentifier("account-change-username-row") - } - - SettingsRow(asset: .profilePicture, title: "Profile Picture", insets: insets) { - router.push(.changeProfilePicture) - } - .accessibilityIdentifier("account-profile-picture-row") - - SettingsRow(asset: .coins, title: "Minimum To Chat", insets: insets) { - router.push(.setMinimumTip(isSetupStep: false)) - } - .accessibilityIdentifier("account-minimum-tip-row") - - SettingsRow(systemImage: "nosign", title: "Blocked", insets: insets) { - router.push(.blockedUsers) - } - } - .font(.appDisplayXS) - .foregroundStyle(.textMain) - } -} diff --git a/Flipcash/Core/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift new file mode 100644 index 000000000..3f3e78f26 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -0,0 +1,192 @@ +// +// SettingsScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The Settings list, pushed from the gear on the You tab: the profile editors, +/// the account-level actions, and the version footer that doubles as the +/// beta-access easter egg. +/// +/// The row icons track Android's through the nearest SF Symbol. Profile Picture +/// is the exception: `familiar_face_and_zone` has no SF equivalent, so it ships +/// as an asset. +struct SettingsScreen: View { + + @Environment(AppRouter.self) private var router + @Environment(Session.self) private var session + @Environment(BetaFlags.self) private var betaFlags + @Environment(SessionAuthenticator.self) private var sessionAuthenticator + @Environment(ContactSyncController.self) private var contactSyncController + @Environment(ToastController.self) private var toasts + + @State private var dialogItem: DialogItem? + /// The version footer's beta-access easter egg — the tap count and the line + /// it shows for the last few taps. + @State private var versionUnlock = VersionTapUnlock() + + // Horizontal inset lives on the rows, not the list: the section headers + // carry their own 20pt. + private let insets = EdgeInsets(top: 25, leading: 20, bottom: 25, trailing: 20) + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + securitySection + privacySection + advancedSection + accountSection + versionFooter + .padding(.top, 32) + } + .font(.appDisplayXS) + .foregroundStyle(.textMain) + .padding(.bottom, 24) + } + } + .navigationTitle("Settings") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialogItem) + } + + // MARK: - Sections - + + private var securitySection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Security") + + SettingsRow(systemImage: "key.horizontal", title: "Access Key", insets: insets) { + dialogItem = .alert( + title: "View Your Access Key?", + subtitle: "Your Access Key will grant access to your Flipcash account. Keep it private and safe" + ) { + DialogAction.destructive("View Access Key") { + router.push(.accessKey) + } + DialogAction.cancel() + } + } + } + } + + private var privacySection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Privacy") + + SettingsRow(systemImage: "nosign", title: "Blocked", insets: insets) { + router.push(.blockedUsers) + } + } + } + + private var advancedSection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Advanced") + + SettingsRow(systemImage: "flask", title: "Beta Features", insets: insets) { + router.push(.settingsAdvancedBetaFeatures) + } + + SettingsRow(systemImage: "doc.text", title: "Application Logs", insets: insets) { + router.push(.settingsApplicationLogs) + } + + if betaFlags.canSwitchAccounts { + SettingsRow(asset: .switchAccounts, title: "Switch Accounts", badge: .beta, insets: insets) { + router.push(.settingsAccountSelection) + } + .accessibilityIdentifier("account-switch-accounts-row") + } + } + } + + private var accountSection: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionHeader("Account") + + if betaFlags.canViewAccountInfo { + SettingsRow(systemImage: "person.crop.circle", title: "Account Info", badge: .beta, insets: insets) { + router.push(.accountInfo) + } + .accessibilityIdentifier("settings-account-info-row") + } + + SettingsRow(asset: .logout, title: "Log Out", insets: insets) { + dialogItem = .alert( + title: "Are You Sure You Want To Log Out?", + subtitle: "You can get into this account using your Access Key" + ) { + DialogAction.destructive("Log Out") { + logout() + } + DialogAction.cancel() + } + } + + SettingsRow(asset: .delete, title: "Delete Account", insets: insets) { + dialogItem = .alert( + title: "Permanently Delete Account?", + subtitle: "This will permanently delete your Flipcash account" + ) { + DialogAction.destructive("Permanently Delete Account") { + deleteAccount() + } + DialogAction.cancel() + } + } + } + } + + private var versionFooter: some View { + Button { + let message = versionUnlock.registerTap(isUnlocked: betaFlags.accessGranted) { + betaFlags.setAccessGranted(!betaFlags.accessGranted) + } + if let message { + // Two seconds and swapped in place, so consecutive taps read as one countdown. Without an action the toast + // lets taps through, so it never blocks the version string at the bottom of the list. + toasts.show( + .init(message, messageIdentifier: "you-version-toast", width: .fit, duration: .seconds(2)), + inPlace: true + ) + } + } label: { + Text("Version \(AppMeta.version) • Build \(AppMeta.build)") + .lineLimit(1) + .font(.appTextHeading) + .foregroundStyle(Color.textSecondary) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("you-version-footer") + } + + // MARK: - Actions - + + private func deleteAccount() { + Task { + router.dismissSheet() + try await Task.delay(milliseconds: 250) + // No server-side account deletion exists; logout only tears down + // locally. Wipe the server's stored contact set first so it isn't + // retained after the account is "deleted". Best-effort — must run + // before logout while the session can still authenticate the call. + await contactSyncController.clearServerContactSetForAccountDeletion() + sessionAuthenticator.logout() + } + } + + private func logout() { + Task { + router.dismissSheet() + try await Task.delay(milliseconds: 250) + sessionAuthenticator.logout() + } + } +} diff --git a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift new file mode 100644 index 000000000..3cd8ecce2 --- /dev/null +++ b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift @@ -0,0 +1,30 @@ +// +// SettingsSectionHeader.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI + +/// The small grey heading that opens a group of rows on a settings screen. +struct SettingsSectionHeader: View { + + let title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + HStack { + Text(title) + .textCase(.uppercase) + .font(.appTextHeading) + .foregroundStyle(.textSecondary) + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 8) + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 1d39d6a57..9a80b3a61 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -578,6 +578,9 @@ final class SessionContainer { /// The emoji the signed-in user reacts with most, for the reaction strip and picker. let recentReactions: RecentReactionsStore + /// The public groups the signed-in user features on their profile. + let featuredGroups: FeaturedGroups + init( session: Session, database: Database, @@ -714,6 +717,10 @@ final class SessionContainer { } let recentReactions = RecentReactionsStore(owner: owner.publicKey) self.recentReactions = recentReactions + self.featuredGroups = FeaturedGroups(fetching: { [weak flipClient] username in + guard let flipClient else { return [] } + return try await flipClient.getFeaturedGroups(owner: owner, username: username) + }) conversationController.reactions.recents = recentReactions conversationController.start() self.conversationController = conversationController 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/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift index 4533ee991..53fd0ea5b 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TransactionDetails.swift @@ -129,7 +129,7 @@ public struct TransactionDetails: Sendable, Equatable, Hashable { /// nothing else. public let canCancel: Bool - /// Whether the counterparty's conversation can be opened from here. + /// Whether the counterparty can be opened from here: their DM, or their profile until it exists. public let canViewInChat: Bool /// The line the header actually renders. @@ -177,10 +177,9 @@ extension TransactionDetails { self.fee = swap?.fee self.received = swap?.toFiat self.canCancel = activity.cancellableCashLinkMetadata != nil - // Opening the conversation needs somebody to open it with, and only a - // user id identifies one — a phone-number counterparty has no chat. The - // display name is not required: the chat screen derives its own header - // from the id (see `AppRouter.Destination.tipConversationForUser`). + // Opening the person needs somebody to open, and only a user id + // identifies one — a phone-number counterparty has no chat or profile. + // The display name is not required: the profile fetches its own. self.canViewInChat = activity.counterparty?.userID != nil } diff --git a/FlipcashTests/Chat/AttachPanelPlacementTests.swift b/FlipcashTests/Chat/AttachPanelPlacementTests.swift index 9a54eb6f4..7498e77c4 100644 --- a/FlipcashTests/Chat/AttachPanelPlacementTests.swift +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -124,7 +124,6 @@ private struct HostedBar: View { var body: some View { ConversationBottomBar( showsSendCash: true, - chatExists: true, conversationID: .test(1), symbol: "$", onSendCash: {}, diff --git a/FlipcashTests/Chat/ChatAttributionNameTests.swift b/FlipcashTests/Chat/ChatAttributionNameTests.swift index b43d66079..c907080a2 100644 --- a/FlipcashTests/Chat/ChatAttributionNameTests.swift +++ b/FlipcashTests/Chat/ChatAttributionNameTests.swift @@ -29,17 +29,4 @@ struct ChatAttributionNameTests { let member = ConversationMember(userID: UserID(), displayName: "") #expect(ConversationLoadCoordinator.attributionName(for: member) == ConversationController.fallbackCounterpartName) } - - @Test("A tip DM with no chat yet titles a nameless profile by its handle, not a blank") - @MainActor func dmCounterpart_emptyName_usesHandle() throws { - let username = try #require(Username("grace")) - let profile = Profile(displayName: "", phone: Phone?.none, email: nil, username: username) - #expect(ConversationScreen.counterpart(userID: UserID(), profile: profile).displayName == username.handle) - } - - @Test("A tip DM with no chat yet titles a nameless profile without a handle as Flipcash User") - @MainActor func dmCounterpart_emptyNameNoHandle_usesFallback() { - let profile = Profile(displayName: "", phone: Phone?.none, email: nil) - #expect(ConversationScreen.counterpart(userID: UserID(), profile: profile).displayName == ConversationController.fallbackCounterpartName) - } } diff --git a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift index c166c3969..e8bfda284 100644 --- a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift +++ b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift @@ -14,35 +14,24 @@ struct ConversationBarLeadingControlTests { private func control( isEditing: Bool = false, - chatExists: Bool = true, showsSendCash: Bool = true ) -> ConversationBarLeadingControl { - ConversationBarLeadingControl(isEditing: isEditing, chatExists: chatExists, showsSendCash: showsSendCash) + ConversationBarLeadingControl(isEditing: isEditing, showsSendCash: showsSendCash) } - @Test("Before the chat exists, Send Cash stays the full-width call to action") - func callToActionBeforeChatExists() { - #expect(control(chatExists: false) == .sendCash) - } - - @Test("Before the chat exists with nothing to send, nothing stands outside the field") - func emptyBeforeChatExistsWithoutCash() { - #expect(control(chatExists: false, showsSendCash: false) == .none) - } - - @Test("Once the chat exists, Send Cash is the round `$` beside the field") - func cashOnceChatExists() { + @Test("Send Cash is the round `$` beside the field") + func cash() { #expect(control() == .cash) } - @Test("Once the chat exists with nothing to send, nothing stands beside the field") - func noneOnceChatExistsWithoutCash() { + @Test("With nothing to send, nothing stands beside the field") + func noneWithoutCash() { #expect(control(showsSendCash: false) == .none) } @Test("An edit takes the slot whatever else is true", arguments: [true, false]) - func cancelWhileEditing(chatExists: Bool) { - #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) + func cancelWhileEditing(showsSendCash: Bool) { + #expect(control(isEditing: true, showsSendCash: showsSendCash) == .cancelEdit) } } @@ -52,13 +41,11 @@ struct ConversationBarBottomRowTests { private func row( isEditing: Bool = false, - chatExists: Bool = true, acceptsMedia: Bool = true, attachedCount: Int = 0 ) -> ConversationBarBottomRow { ConversationBarBottomRow( isEditing: isEditing, - chatExists: chatExists, acceptsMedia: acceptsMedia, attachedCount: attachedCount ) @@ -88,9 +75,4 @@ struct ConversationBarBottomRowTests { func hiddenWhileEditing() { #expect(row(isEditing: true).isEmpty) } - - @Test("Before the chat exists there is no bottom row") - func hiddenBeforeChatExists() { - #expect(row(chatExists: false).isEmpty) - } } diff --git a/FlipcashTests/Chat/MentionDestinationTests.swift b/FlipcashTests/Chat/MentionDestinationTests.swift index c1bda37a4..3958f7278 100644 --- a/FlipcashTests/Chat/MentionDestinationTests.swift +++ b/FlipcashTests/Chat/MentionDestinationTests.swift @@ -59,9 +59,4 @@ import FlipcashCore @Test func aFailedLookupIsNotReadAsUnclaimed() { #expect(MentionDestination.destination(for: .failure(Offline()), counterpart: nil) == .lookupFailed) } - - @Test func aMentionOffersChatActionsButNotMute() { - #expect(UserProfileOrigin.mention.showsChatActions(profileUserID: Self.otherID, selfUserID: Self.counterpartID)) - #expect(!UserProfileOrigin.mention.showsMute) - } } diff --git a/FlipcashTests/EditBioModelTests.swift b/FlipcashTests/EditBioModelTests.swift new file mode 100644 index 000000000..802858171 --- /dev/null +++ b/FlipcashTests/EditBioModelTests.swift @@ -0,0 +1,135 @@ +// +// EditBioModelTests.swift +// FlipcashTests +// + +import Testing +import FlipcashAPI +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("EditBioModel") +struct EditBioModelTests { + + private struct Boom: Error {} + + @MainActor + private final class Calls { + var saved: [String] = [] + var refreshCount = 0 + var order: [String] = [] + } + + private func makeModel( + bio: String = "Hello", + calls: Calls = Calls(), + saving: ((String) async throws -> Void)? = nil + ) -> EditBioModel { + EditBioModel( + bio: bio, + saving: { text in + calls.order.append("save") + calls.saved.append(text) + try await saving?(text) + }, + refresh: { + calls.order.append("refresh") + calls.refreshCount += 1 + } + ) + } + + @Test("The text starts as the current bio and the counter counts down from 160") + func initialState() { + let model = makeModel(bio: "Hello") + + #expect(model.text == "Hello") + #expect(model.remaining == 155) + } + + @Test("Save is shut while unchanged or over the limit, open when cleared") + func canSave() { + let model = makeModel(bio: "Hello") + #expect(!model.canSave) + + model.text = "Hello there" + #expect(model.canSave) + + model.text = String(repeating: "a", count: 161) + #expect(model.remaining == -1) + #expect(!model.canSave) + + model.text = String(repeating: "a", count: 160) + #expect(model.canSave) + + model.text = "" + #expect(model.canSave) + } + + @Test("A successful save sends the trimmed text, then refreshes, then reports saved") + func saveSucceeds() async { + let calls = Calls() + let model = makeModel(calls: calls) + model.text = " New bio \n" + + await model.save() + + #expect(calls.saved == ["New bio"]) + #expect(calls.order == ["save", "refresh"]) + #expect(model.state == .saved) + #expect(model.fieldError == nil) + #expect(model.failure == nil) + } + + @Test("Moderation shows an inline error and keeps the text") + func moderated() async { + let calls = Calls() + let model = makeModel(calls: calls) { _ in throw ErrorProfile.moderated(.init()) } + model.text = "flagged" + + await model.save() + + #expect(model.state == .normal) + #expect(model.fieldError == .moderated) + #expect(model.text == "flagged") + #expect(model.failure == nil) + #expect(calls.refreshCount == 0) + } + + @Test("An invalid bio shows an inline error and keeps the text") + func invalid() async { + let model = makeModel { _ in throw ErrorProfile.invalidBio } + model.text = "bad" + + await model.save() + + #expect(model.state == .normal) + #expect(model.fieldError == .invalid) + #expect(model.text == "bad") + } + + @Test("Any other error has no field to blame and raises a failure") + func otherError() async { + let model = makeModel { _ in throw Boom() } + model.text = "something" + + await model.save() + + #expect(model.state == .normal) + #expect(model.fieldError == nil) + #expect(model.failure != nil) + } + + @Test("Editing the text clears the inline error") + func editingClearsFieldError() async { + let model = makeModel { _ in throw ErrorProfile.invalidBio } + model.text = "bad" + await model.save() + #expect(model.fieldError == .invalid) + + model.text = "better" + + #expect(model.fieldError == nil) + } +} diff --git a/FlipcashTests/EditFeaturedGroupsModelTests.swift b/FlipcashTests/EditFeaturedGroupsModelTests.swift new file mode 100644 index 000000000..e3d12532d --- /dev/null +++ b/FlipcashTests/EditFeaturedGroupsModelTests.swift @@ -0,0 +1,218 @@ +// +// EditFeaturedGroupsModelTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("EditFeaturedGroupsModel") +struct EditFeaturedGroupsModelTests { + + private struct Boom: Error {} + + @MainActor + private final class Calls { + var submitted: [[ConversationID]] = [] + var handedBack: [[Conversation]] = [] + } + + private func group(_ byte: UInt8, title: String? = nil, isPrivate: Bool = false, type: ConversationType = .group) -> Conversation { + Conversation( + id: ConversationID(data: Data(repeating: byte, count: 32)), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: type, + title: title, + isPrivate: isPrivate + ) + } + + private func makeModel( + cached: [Conversation] = [], + featured: [Conversation]? = [], + joined: [Conversation] = [], + calls: Calls = Calls(), + saving: (([ConversationID]) async throws -> [Conversation])? = nil + ) -> EditFeaturedGroupsModel { + EditFeaturedGroupsModel( + featured: cached, + loadingFeatured: { featured }, + joinedGroups: { joined }, + saving: { ids in + calls.submitted.append(ids) + return try await saving?(ids) ?? [] + }, + saved: { calls.handedBack.append($0) } + ) + } + + @Test("Candidates are the featured groups in their order, then joined public groups") + func candidatesOrder() async { + let a = group(1), b = group(2), c = group(3), secret = group(4, isPrivate: true), dm = group(5, type: .tipDm) + let model = makeModel(featured: [b, a], joined: [a, c, secret, dm]) + + await model.loadCandidates() + + #expect(model.candidates.map(\.id) == [b.id, a.id, c.id]) + #expect(model.selection == [b.id, a.id]) + #expect(model.loadState == .loaded) + } + + @Test("A featured group the user has left stays on offer so it can be removed") + func leftGroupStays() async { + let left = group(1) + let model = makeModel(featured: [left], joined: []) + + await model.loadCandidates() + + #expect(model.candidates.map(\.id) == [left.id]) + #expect(model.isSelected(left.id)) + } + + @Test("Toggling appends at the end of the order and removes in place") + func toggleOrder() async { + let a = group(1), b = group(2), c = group(3) + let model = makeModel(featured: [a], joined: [b, c]) + await model.loadCandidates() + + model.toggle(c.id) + model.toggle(b.id) + #expect(model.selection == [a.id, c.id, b.id]) + + model.toggle(a.id) + #expect(model.selection == [c.id, b.id]) + } + + @Test("No more than ten can be chosen, and a chosen one can still be dropped") + func capsAtLimit() async { + let groups = (1...11).map { group(UInt8($0)) } + let model = makeModel(featured: [], joined: groups) + await model.loadCandidates() + + groups.prefix(FeaturedGroups.limit).forEach { model.toggle($0.id) } + #expect(model.selection.count == FeaturedGroups.limit) + #expect(!model.canToggle(groups[10].id)) + + model.toggle(groups[10].id) + #expect(model.selection.count == FeaturedGroups.limit) + + #expect(model.canToggle(groups[0].id)) + model.toggle(groups[0].id) + #expect(model.selection.count == FeaturedGroups.limit - 1) + } + + @Test("Save stays shut until the featured list is read, and while unchanged") + func canSave() async { + let a = group(1), b = group(2) + let model = makeModel(cached: [a], featured: [a], joined: [b]) + + model.toggle(a.id) + #expect(!model.canSave) + + await model.loadCandidates() + // A choice made while loading stands. + #expect(model.selection.isEmpty) + #expect(model.canSave) + + model.toggle(a.id) + #expect(!model.canSave) + } + + @Test("A failed read of the featured list keeps Save shut") + func failedLoad() async { + let a = group(1) + let model = makeModel(cached: [], featured: nil, joined: [a]) + + await model.loadCandidates() + model.toggle(a.id) + + #expect(model.loadState == .failed) + #expect(!model.canSave) + } + + @Test("An untouched selection follows the re-read list") + func untouchedSelectionFollowsServer() async { + let stale = group(1), fresh = group(2) + let model = makeModel(cached: [stale], featured: [fresh]) + + await model.loadCandidates() + + #expect(model.selection == [fresh.id]) + #expect(!model.canSave) + } + + @Test("Saving submits the ordered selection and hands back the server's list") + func saveSubmits() async { + let a = group(1), b = group(2) + let calls = Calls() + let model = makeModel(featured: [a], joined: [b], calls: calls) { _ in [b, a] } + await model.loadCandidates() + model.toggle(a.id) + model.toggle(b.id) + model.toggle(a.id) + + await model.save() + + #expect(calls.submitted == [[b.id, a.id]]) + #expect(calls.handedBack.map { $0.map(\.id) } == [[b.id, a.id]]) + #expect(model.state == .saved) + } + + @Test("Clearing every group saves an empty list") + func saveEmpty() async { + let a = group(1) + let calls = Calls() + let model = makeModel(featured: [a], calls: calls) + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(calls.submitted == [[]]) + } + + @Test("A denied save names the private-group failure and leaves the selection") + func deniedSave() async { + let a = group(1) + let model = makeModel(featured: [], joined: [a]) { _ in throw ErrorSetFeaturedGroups.denied } + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(model.failure == .privateGroup) + #expect(model.state == .normal) + #expect(model.selection == [a.id]) + } + + @Test("Any other failure is generic") + func otherFailure() async { + let a = group(1) + let model = makeModel(featured: [], joined: [a]) { _ in throw Boom() } + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(model.failure == .other) + #expect(model.state == .normal) + } + + @Test("Search filters by title, ignoring case") + func search() async { + let cats = group(1, title: "Cat People"), dogs = group(2, title: "Dog people") + let model = makeModel(featured: [], joined: [cats, dogs]) + await model.loadCandidates() + + model.query = "cat" + #expect(model.visibleCandidates.map(\.id) == [cats.id]) + + model.query = " " + #expect(model.visibleCandidates.count == 2) + } +} diff --git a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift index 4ae532dd0..fd1ff0ad2 100644 --- a/FlipcashTests/Navigation/AppRouterCrossStackTests.swift +++ b/FlipcashTests/Navigation/AppRouterCrossStackTests.swift @@ -150,9 +150,9 @@ struct AppRouterCrossStackTests { (AppRouter.Destination.withdrawCurrency(.usdc), AppRouter.Stack.balance), (AppRouter.Destination.usdcDepositEducation, AppRouter.Stack.balance), (AppRouter.Destination.usdcDepositAddress, AppRouter.Stack.balance), - (AppRouter.Destination.settingsMyAccount, AppRouter.Stack.you), + (AppRouter.Destination.settings, AppRouter.Stack.you), + (AppRouter.Destination.accountInfo, AppRouter.Stack.you), (AppRouter.Destination.changeDisplayName, AppRouter.Stack.you), - (AppRouter.Destination.settingsAdvancedFeatures, AppRouter.Stack.you), (AppRouter.Destination.settingsAppSettings, AppRouter.Stack.you), (AppRouter.Destination.settingsAdvancedBetaFeatures, AppRouter.Stack.you), (AppRouter.Destination.settingsAccountSelection, AppRouter.Stack.you), diff --git a/FlipcashTests/Navigation/AppRouterTests.swift b/FlipcashTests/Navigation/AppRouterTests.swift index 051645399..57ac1e587 100644 --- a/FlipcashTests/Navigation/AppRouterTests.swift +++ b/FlipcashTests/Navigation/AppRouterTests.swift @@ -370,7 +370,7 @@ struct AppRouterTests { AppRouter.Destination.discoverCurrencies, AppRouter.Destination.currencyCreationSummary, AppRouter.Destination.currencyCreationWizard, - AppRouter.Destination.settingsMyAccount, + AppRouter.Destination.settings, AppRouter.Destination.withdraw, ] ) diff --git a/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift b/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift index 8cbe82b52..a204a2620 100644 --- a/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift +++ b/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift @@ -14,32 +14,32 @@ struct HomeTabBarVisibilityTests { func walletCard_hidesOnlyOnWallet(tab: HomeTab) { let hidden = tab.hidesTabBar( isWalletCardExpanded: true, - isTipCardExpanded: false, isShowingBill: false, + isShowingProfileCard: 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) { + @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, isShowingBill: true, isShowingProfileCard: false, hasPushedScreen: false)) + #expect(tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: false, isShowingProfileCard: false, hasPushedScreen: true)) + } + + @Test("the profile card hides the bar only on the You tab", arguments: HomeTab.allCases) + func profileCard_hidesOnlyOnYou(tab: HomeTab) { let hidden = tab.hidesTabBar( isWalletCardExpanded: false, - isTipCardExpanded: true, isShowingBill: false, + isShowingProfileCard: true, 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)) - } - @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, isShowingProfileCard: false, hasPushedScreen: false)) } } diff --git a/FlipcashTests/Navigation/SettingsRoutingTests.swift b/FlipcashTests/Navigation/SettingsRoutingTests.swift new file mode 100644 index 000000000..7aaaab98e --- /dev/null +++ b/FlipcashTests/Navigation/SettingsRoutingTests.swift @@ -0,0 +1,56 @@ +// +// SettingsRoutingTests.swift +// FlipcashTests +// + +import Testing +@testable import Flipcash + +@MainActor +@Suite("Settings routing") +struct SettingsRoutingTests { + + @Test("Settings, Account Info and the profile editors are owned by the You stack", arguments: [ + AppRouter.Destination.settings, + AppRouter.Destination.accountInfo, + AppRouter.Destination.editProfile, + AppRouter.Destination.editBio, + AppRouter.Destination.editFeaturedGroups, + AppRouter.Destination.changeCoverPicture, + ]) + func owningStack(destination: AppRouter.Destination) { + #expect(destination.owningStack == .you) + } + + @Test("Pushing Settings from the You tab lands on the You stack") + func pushLandsOnYou() { + let router = AppRouter() + + router.activeTabStack = .you + router.push(.settings) + + #expect(router[.you] == AppRouter.navigationPath(.settings)) + } + + @Test("Popping Edit Bio after a save returns to Edit Profile") + func savePopsBackToEditProfile() { + let router = AppRouter() + + router.activeTabStack = .you + router.push(.editProfile) + router.push(.editBio) + #expect(router[.you] == AppRouter.navigationPath(.editProfile, .editBio)) + + router.popTopmost() + + #expect(router[.you] == AppRouter.navigationPath(.editProfile)) + } + + @Test("Log names are stable and carry no payload") + func logNames() { + #expect(AppRouter.Destination.settings.description == "settings") + #expect(AppRouter.Destination.accountInfo.description == "accountInfo") + #expect(AppRouter.Destination.settings.payload == nil) + #expect(AppRouter.Destination.accountInfo.payload == nil) + } +} diff --git a/FlipcashTests/Navigation/SwitchAccountSheetTests.swift b/FlipcashTests/Navigation/SwitchAccountSheetTests.swift index a40a7338e..c9cb79ee2 100644 --- a/FlipcashTests/Navigation/SwitchAccountSheetTests.swift +++ b/FlipcashTests/Navigation/SwitchAccountSheetTests.swift @@ -43,14 +43,14 @@ struct SwitchAccountSheetTests { func dismiss_returnsToTab() { let router = AppRouter() router.activeTabStack = .you - router.push(.settingsMyAccount) + router.push(.settings) router.present(.switchAccount) router.dismissSheet() #expect(router.presentedSheet == nil) #expect(router.activeTabStack == .you) - #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount)) + #expect(router[.you] == AppRouter.navigationPath(.settings)) } @Test("a self tipcard scan dismisses the switcher on its way to the You tab") diff --git a/FlipcashTests/Navigation/YouTabRoutingTests.swift b/FlipcashTests/Navigation/YouTabRoutingTests.swift index 075464e84..a5a11e1ef 100644 --- a/FlipcashTests/Navigation/YouTabRoutingTests.swift +++ b/FlipcashTests/Navigation/YouTabRoutingTests.swift @@ -22,8 +22,8 @@ struct YouTabRoutingTests { func push_onActiveYouTab_landsOnYouStack() { let router = AppRouter() router.activeTabStack = .you - router.push(.settingsMyAccount) - #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount)) + router.push(.settings) + #expect(router[.you] == AppRouter.navigationPath(.settings)) #expect(router[.balance].isEmpty) // never leaks onto another tab's stack } @@ -31,7 +31,7 @@ struct YouTabRoutingTests { func showOwnTipCard_inTabUI_selectsYouTabAtRoot() { let router = AppRouter() // Drilled into My Account behind a sheet — where a self link can land. - router.setPath([.settingsMyAccount], on: .you) + router.setPath([.settings], on: .you) router.present(.give) router.showOwnTipCard() @@ -60,7 +60,7 @@ struct YouTabRoutingTests { func showOwnTipCard_fromPushedYouScreen_popsToRoot() { let router = AppRouter() router.activeTabStack = .you - router.push(.settingsMyAccount) + router.push(.settings) router.showOwnTipCard() @@ -72,13 +72,13 @@ struct YouTabRoutingTests { func changeDisplayName_pushesAndPopsOnYouStack() { let router = AppRouter() router.activeTabStack = .you - router.push(.settingsMyAccount) + router.push(.settings) router.push(.changeDisplayName) - #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount, .changeDisplayName)) + #expect(router[.you] == AppRouter.navigationPath(.settings, .changeDisplayName)) // What `ProfileNameScreen(completion: .back)` runs once the name saves. router.popTopmost() - #expect(router[.you] == AppRouter.navigationPath(.settingsMyAccount)) + #expect(router[.you] == AppRouter.navigationPath(.settings)) } @Test("changing the profile picture belongs to the You tab, not the setup flow's stack") @@ -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/PostPaymentChatTests.swift b/FlipcashTests/PostPaymentChatTests.swift new file mode 100644 index 000000000..7f76fd87e --- /dev/null +++ b/FlipcashTests/PostPaymentChatTests.swift @@ -0,0 +1,76 @@ +// +// PostPaymentChatTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("PostPaymentChat") +struct PostPaymentChatTests { + + private let dmID = ConversationID(data: Data(repeating: 7, count: 32)) + + @Test("A record that landed before the sheet dismissed is pushed at dismissal") + func recordFirst_pushesAtDismissal() { + var state = PostPaymentChat() + state.paymentSucceeded() + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: true) == dmID) + #expect(!state.isAwaitingChat) + } + + @Test("A record that lands after dismissal is pushed on arrival, once") + func dismissalFirst_pushesOnArrival() { + var state = PostPaymentChat() + state.paymentSucceeded() + #expect(state.sheetDismissed(dmID: nil, isScreenInFront: true) == nil) + #expect(state.isAwaitingChat) + #expect(state.dmArrived(dmID, isScreenInFront: true) == dmID) + #expect(state.dmArrived(dmID, isScreenInFront: true) == nil) + } + + @Test("Dismissing without a payment pushes nothing and waits for nothing") + func noPayment_nothing() { + var state = PostPaymentChat() + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: true) == nil) + #expect(!state.isAwaitingChat) + } + + @Test("A late success after an early dismissal doesn't leak into the next dismissal") + func lateSuccess_doesNotLeak() { + var state = PostPaymentChat() + #expect(state.sheetDismissed(dmID: nil, isScreenInFront: true) == nil) + state.paymentSucceeded() + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: true) == dmID) + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: true) == nil) + } + + @Test("A screen no longer in front drops the push at dismissal") + func notInFront_atDismissal_drops() { + var state = PostPaymentChat() + state.paymentSucceeded() + #expect(state.sheetDismissed(dmID: dmID, isScreenInFront: false) == nil) + #expect(!state.isAwaitingChat) + } + + @Test("A screen no longer in front drops the push on arrival") + func notInFront_onArrival_drops() { + var state = PostPaymentChat() + state.paymentSucceeded() + _ = state.sheetDismissed(dmID: nil, isScreenInFront: true) + #expect(state.dmArrived(dmID, isScreenInFront: false) == nil) + #expect(!state.isAwaitingChat) + } + + @Test("Giving up stops the wait") + func gaveUp_stopsWaiting() { + var state = PostPaymentChat() + state.paymentSucceeded() + _ = state.sheetDismissed(dmID: nil, isScreenInFront: true) + state.gaveUp() + #expect(state.dmArrived(dmID, isScreenInFront: true) == nil) + } +} 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/ProfileCreationStateTests.swift b/FlipcashTests/ProfileCreationStateTests.swift index d362d9d39..574b37895 100644 --- a/FlipcashTests/ProfileCreationStateTests.swift +++ b/FlipcashTests/ProfileCreationStateTests.swift @@ -101,6 +101,25 @@ struct ProfileCreationStateTests { #expect(state.selectedImage == nil) } + @Test("The cover slot attaches through the cover call, the avatar slot through the avatar call") + func slotRoutesAttach() async throws { + let blobID = BlobID(data: Data(repeating: 7, count: 16)) + var calls: [String] = [] + + try await SessionProfilePictureUploader.attach( + blobID, to: .cover, + setAvatar: { _ in calls.append("avatar") }, + setCover: { id in #expect(id == blobID); calls.append("cover") } + ) + try await SessionProfilePictureUploader.attach( + blobID, to: .avatar, + setAvatar: { id in #expect(id == blobID); calls.append("avatar") }, + setCover: { _ in calls.append("cover") } + ) + + #expect(calls == ["cover", "avatar"]) + } + @Test("Uploading without a photo fails before reserving anything") func uploadingWithoutAPhotoDoesNotReserve() async throws { let uploader = StubUploader() @@ -172,7 +191,7 @@ private final class StubUploader: ProfilePictureUploading { try finalization.get() } - func setProfilePicture(blobID: BlobID) async throws { + func attach(blobID: BlobID) async throws { setPictureCount += 1 } diff --git a/FlipcashTests/ProfileMenuItemsTests.swift b/FlipcashTests/ProfileMenuItemsTests.swift new file mode 100644 index 000000000..f4568773d --- /dev/null +++ b/FlipcashTests/ProfileMenuItemsTests.swift @@ -0,0 +1,41 @@ +// +// ProfileMenuItemsTests.swift +// FlipcashTests +// + +import Testing +@testable import Flipcash + +@MainActor +@Suite("ProfileMenuItems") +struct ProfileMenuItemsTests { + + @Test("A DM offers Mute, Report and Block") + func dm_unmuted() { + #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: true) == [.mute, .report, .block]) + } + + @Test("Without a DM there is nothing to mute") + func noDM() { + #expect(ProfileMenuItems.resolve(isBlocked: false, hasDM: false) == [.report, .block]) + } + + @Test("A blocked profile offers Report and Unblock, with no Mute", arguments: [true, false]) + func blocked(hasDM: Bool) { + #expect(ProfileMenuItems.resolve(isBlocked: true, hasDM: hasDM) == [.report, .unblock]) + } + + @Test("Mute reads as in the chat mute row") + func muteLabel() { + #expect(ProfileMenuItem.mute.title == "Mute Notifications") + #expect(ProfileMenuItem.mute.systemImage == "bell.slash") + } + + @Test("Report and Block are destructive; Unblock and Mute are not") + func destructive() { + #expect(ProfileMenuItem.report.isDestructive) + #expect(ProfileMenuItem.block.isDestructive) + #expect(!ProfileMenuItem.unblock.isDestructive) + #expect(!ProfileMenuItem.mute.isDestructive) + } +} diff --git a/FlipcashTests/ProfilePinnedActionTests.swift b/FlipcashTests/ProfilePinnedActionTests.swift new file mode 100644 index 000000000..e80725b71 --- /dev/null +++ b/FlipcashTests/ProfilePinnedActionTests.swift @@ -0,0 +1,55 @@ +// +// ProfilePinnedActionTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("ProfilePinnedAction") +struct ProfilePinnedActionTests { + + private let fee = FiatAmount(value: Decimal(string: "0.50")!, currency: .usd) + private let dmID = ConversationID(data: Data(repeating: 7, count: 32)) + + @Test("The viewer's own profile pins nothing") + func isSelf_none() { + #expect(ProfilePinnedAction.resolve(isSelf: true, isBlocked: false, dmID: dmID, fee: fee) == .none) + } + + @Test("A blocked profile pins Unblock whether or not a DM exists", arguments: [true, false]) + func blocked_unblock(hasDM: Bool) { + let action = ProfilePinnedAction.resolve(isSelf: false, isBlocked: true, dmID: hasDM ? dmID : nil, fee: fee) + #expect(action == .unblock) + } + + @Test("An existing DM pins Open Chat") + func dm_openChat() { + let action = ProfilePinnedAction.resolve(isSelf: false, isBlocked: false, dmID: dmID, fee: fee) + #expect(action == .openChat(dmID)) + } + + @Test("No DM and a fee pins the priced start") + func noDM_fee_startChatting() { + let action = ProfilePinnedAction.resolve(isSelf: false, isBlocked: false, dmID: nil, fee: fee) + #expect(action == .startChatting(fee)) + } + + @Test("No DM and no fee pins the unpriced start") + func noDM_noFee_unpriced() { + let action = ProfilePinnedAction.resolve(isSelf: false, isBlocked: false, dmID: nil, fee: nil) + #expect(action == .startChattingUnpriced) + } + + @Test("Labels") + func labels() { + #expect(ProfilePinnedAction.startChatting(fee).title == "Send \(fee.formatted()) to Start Chatting") + #expect(ProfilePinnedAction.startChattingUnpriced.title == "Start Chatting") + #expect(ProfilePinnedAction.openChat(dmID).title == "Open Chat") + #expect(ProfilePinnedAction.unblock.title == "Unblock") + #expect(ProfilePinnedAction.none.title == nil) + } +} diff --git a/FlipcashTests/ProfileTutorialStateTests.swift b/FlipcashTests/ProfileTutorialStateTests.swift deleted file mode 100644 index 9866dd4ff..000000000 --- a/FlipcashTests/ProfileTutorialStateTests.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// ProfileTutorialStateTests.swift -// FlipcashTests -// - -import Foundation -import Testing -import FlipcashCore -@testable import Flipcash - -/// The checklist is derived from the profile rather than from a dismissal flag, -/// so it must stay silent until a profile has loaded and must disappear on its -/// own once every chore is done. -@Suite -@MainActor -struct ProfileTutorialStateTests { - - private static func state( - hasProfile: Bool = true, - name: Bool = true, - picture: Bool = false, - minimumTip: Bool = false - ) -> ProfileTutorialState { - ProfileTutorialState( - hasProfile: hasProfile, - hasDisplayName: name, - hasProfilePicture: picture, - hasMinimumTipAmount: minimumTip - ) - } - - @Test("The checklist is withheld until a profile has loaded") - func withheldWithoutProfile() { - #expect(!Self.state(hasProfile: false).isVisible) - } - - @Test("The checklist shows while any chore is outstanding") - func shownWhileIncomplete() { - #expect(Self.state().isVisible) - #expect(Self.state(name: false, picture: true, minimumTip: true).isVisible) - #expect(Self.state(picture: true).isVisible) - #expect(Self.state(minimumTip: true).isVisible) - } - - @Test("A finished checklist disappears without needing a dismissal") - func hiddenWhenComplete() { - let state = Self.state(picture: true, minimumTip: true) - #expect(state.isComplete) - #expect(!state.isVisible) - } - - @Test("Profile fields drive the step completion the card renders") - func itemsCarryProfileState() { - let state = Self.state(picture: true) - #expect(state.items == [ - .displayName(isCompleted: true), - .profilePicture(isCompleted: true), - .minimumTipAmount(isCompleted: false), - ]) - } - - @Test("A name-less profile gets the checklist, with naming it first and outstanding") - func namelessProfileShowsNameChore() { - let state = ProfileTutorialState(profile: Profile(displayName: nil, phone: Phone?.none, email: nil)) - #expect(state.isVisible) - #expect(state.items.first == .displayName(isCompleted: false)) - } -} diff --git a/FlipcashTests/Settings/AccountInfoDetailsTests.swift b/FlipcashTests/Settings/AccountInfoDetailsTests.swift new file mode 100644 index 000000000..923abc168 --- /dev/null +++ b/FlipcashTests/Settings/AccountInfoDetailsTests.swift @@ -0,0 +1,80 @@ +// +// AccountInfoDetailsTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@Suite("AccountInfoDetails") +struct AccountInfoDetailsTests { + + private let userID = UUID() + private let authority = PublicKey.usdc + + private func profile(phone: Phone?, email: String?) -> Profile { + Profile(displayName: "Ada", phone: phone, email: email) + } + + @Test("All fields present: phone, email, account ID, public key in order") + func allFields() throws { + let phone = try #require(Phone("+14155552671")) + let rows = AccountInfoDetails.rows( + profile: profile(phone: phone, email: "ada@example.com"), + userID: userID, + authority: authority + ) + + #expect(rows.map(\.title) == ["Phone", "Email", "Account ID", "Public Key"]) + #expect(rows.map(\.display) == [phone.national, "ada@example.com", userID.uuidString, authority.base58]) + #expect(rows.map(\.accessibilityID) == [ + "account-info-phone", "account-info-email", "account-info-account-id", "account-info-public-key", + ]) + } + + @Test("Phone copies the E.164 form, not the national display") + func phoneCopiesE164() throws { + let phone = try #require(Phone("+14155552671")) + let rows = AccountInfoDetails.rows( + profile: profile(phone: phone, email: nil), + userID: userID, + authority: authority + ) + + let row = try #require(rows.first) + #expect(row.copyValue == phone.e164) + #expect(row.display == phone.national) + } + + @Test("No phone and no email leaves account ID and public key") + func noContact() { + let rows = AccountInfoDetails.rows( + profile: profile(phone: nil, email: nil), + userID: userID, + authority: authority + ) + + #expect(rows.map(\.title) == ["Account ID", "Public Key"]) + #expect(rows.map(\.copyValue) == [userID.uuidString, authority.base58]) + } + + @Test("An empty email string yields no email row") + func emptyEmail() { + let rows = AccountInfoDetails.rows( + profile: profile(phone: nil, email: ""), + userID: userID, + authority: authority + ) + + #expect(!rows.contains { $0.title == "Email" }) + } + + @Test("A missing profile still shows account ID and public key") + func nilProfile() { + let rows = AccountInfoDetails.rows(profile: nil, userID: userID, authority: authority) + + #expect(rows.map(\.title) == ["Account ID", "Public Key"]) + } +} 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..448a6d735 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(" Profile { - Profile( - displayName: displayName, - phone: Optional.none, - email: nil, - username: username.flatMap { Username($0) } - ) - } - // MARK: - Destination - - @Test("The chat destination belongs to the tips stack") - func destination_ownedByTipsStack() { - #expect(AppRouter.Destination.tipConversationForUser(UUID()).owningStack == .tips) - } - - @Test("The chat destination logs the counterpart as its payload") - func destination_payloadIsCounterpart() { + @Test("Without a DM, the lookup lands on the profile, owned by the tips stack, keyed by the user") + func destination_withoutDMIsTheProfile() { let userID = UUID() - let destination = AppRouter.Destination.tipConversationForUser(userID) - #expect(destination.description == "tipConversationForUser") + let destination = DMRoute.destination(for: userID, dmID: nil, origin: .usernameLookup) + #expect(destination == .userProfile(userID, origin: .usernameLookup)) + #expect(destination.owningStack == .tips) #expect(destination.payload == userID.uuidString) } - @Test("Two lookups of the same person are the same destination") - func destination_isStablePerCounterpart() { - let userID = UUID() - #expect( - AppRouter.Destination.tipConversationForUser(userID) - == AppRouter.Destination.tipConversationForUser(userID) - ) + @Test("With a DM, the lookup lands on the chat") + func destination_withDMIsTheChat() { + let (me, them) = (UUID(), UUID()) + let dmID = ConversationID.tipDm(between: me, and: them) + #expect(DMRoute.destination(for: them, dmID: dmID, origin: .usernameLookup) == .tipConversation(dmID)) } // MARK: - Context - @@ -58,27 +42,6 @@ struct UsernameLookupRoutingTests { #expect(ConversationID.tipDm(between: me, and: them) == .tipDm(between: them, and: me)) } - @Test("A tip DM counterpart is never matched to an address-book contact") - func context_neverResolvesAContact() { - // Tip DMs identify people by profile. A directory entry that happens to - // carry the derived chat id must not retitle the screen or redirect the - // send to a phone number. - let (me, them) = (UUID(), UUID()) - let chatID = ConversationID.tipDm(between: me, and: them) - let contact = ResolvedContact( - contactId: "abc", - displayName: "Fred From My Phone", - phoneE164: "+15551234567", - nationalPhone: "(555) 123-4567", - imageData: nil, - dmChatID: chatID.data - ) - let context = ConversationContext.tipDM(counterpart: them) - #expect(context.resolvedContact(in: [contact]) == nil) - // The same directory does resolve for a chat reached by its id. - #expect(ConversationContext.existing(chatID).resolvedContact(in: [contact]) != nil) - } - // MARK: - Back stack - @Test("The chat list is not a fixed number of screens below the lookup") @@ -95,64 +58,26 @@ struct UsernameLookupRoutingTests { #expect(router[.tips].count == 2) } - @Test("Back from a chat opened by handle lands on the chat list") - func backStack_rewriteLeavesOnlyTheChat() { + @Test( + "Back from a profile or chat opened by handle lands on the chat list", + arguments: [false, true] + ) + func backStack_rewriteLeavesOnlyTheDestination(hasDM: Bool) { let router = AppRouter() router.activeTabStack = .tips router.push(.newChat) router.push(.usernameLookup) - let chat = AppRouter.Destination.tipConversationForUser(UUID()) - let depthWithChat = router[.tips].count + 1 - router.push(chat) - #expect(router[.tips].count == depthWithChat) + let them = UUID() + let dmID = hasDM ? ConversationID.tipDm(between: UUID(), and: them) : nil + let destination = DMRoute.destination(for: them, dmID: dmID, origin: .usernameLookup) + let depthWithDestination = router[.tips].count + 1 + router.push(destination) + #expect(router[.tips].count == depthWithDestination) // What the screen does once the push has started: neither the picker // nor the lookup is somewhere Back belongs, and the list is the root. - router.setPath([chat], on: .tips) + router.setPath([destination], on: .tips) #expect(router[.tips].count == 1) } - - // MARK: - Counterpart - - - @Test("The fetched profile supplies the name and handle the chat shows") - func counterpart_carriesNameAndHandle() { - let userID = UUID() - let member = ConversationScreen.counterpart( - userID: userID, - profile: Self.profile(displayName: "Fred Wilson", username: "fred_wilson") - ) - #expect(member.userID == userID) - #expect(member.displayName == "Fred Wilson") - #expect(member.username == Username("fred_wilson")) - } - - @Test("A name-less account is titled by its handle") - func counterpart_fallsBackToTheHandleForANamelessAccount() { - // Claiming a handle doesn't require a display name, so this is a real - // account, not a malformed response — the chat needs a title regardless. - let member = ConversationScreen.counterpart( - userID: UUID(), - profile: Self.profile(displayName: nil, username: "fred_wilson") - ) - #expect(member.displayName == "@fred_wilson") - } - - @Test("An account with neither a name nor a handle gets the fallback title") - func counterpart_fallsBackForAnAccountWithNoNameOrHandle() { - let member = ConversationScreen.counterpart( - userID: UUID(), - profile: Self.profile(displayName: nil) - ) - #expect(member.displayName == ConversationController.fallbackCounterpartName) - } - - @Test("A counterpart with no handle renders the name-only card") - func counterpart_withoutAHandleShowsNoHandleLine() { - let member = ConversationScreen.counterpart( - userID: UUID(), - profile: Self.profile(displayName: "Fred Wilson") - ) - #expect(ConversationScreen.tipDMCounterpart(member) == ChatProfileCard.Counterpart.none) - } } diff --git a/FlipcashUI/Sources/FlipcashUI/Modifiers/FocusAfterPush.swift b/FlipcashUI/Sources/FlipcashUI/Modifiers/FocusAfterPush.swift new file mode 100644 index 000000000..13ff936dd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Modifiers/FocusAfterPush.swift @@ -0,0 +1,21 @@ +// +// FocusAfterPush.swift +// FlipcashUI +// + +import SwiftUI + +extension View { + + /// Focuses `focus` once the push that brought this screen in has finished, so the keyboard and + /// anything pinned above it rise on a screen that is already in place. + public func focusAfterPush(_ focus: FocusState.Binding) -> some View { + task { + // A navigation push runs about 0.5s; focusing during it slides the keyboard's + // accessories in from off screen alongside the incoming view. + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + focus.wrappedValue = true + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift b/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift index a753c172b..e43fbb126 100644 --- a/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift +++ b/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift @@ -20,6 +20,13 @@ extension View { modifier(SoftScrollEdge(edges: edges)) } + /// Hides the top scroll edge effect until the scroll view has moved past `offset`, for a screen + /// whose content starts with a full-bleed image under the bar: at rest the effect would only + /// darken that image and leave a visible band where it stops. No-op below iOS 26. + public func hidesTopScrollEdge(untilOffset offset: CGFloat) -> some View { + modifier(HidesTopScrollEdge(offset: offset)) + } + /// Pins `bar` to `edge` so the scroll content below it runs underneath. On iOS 26+ the bar joins /// the scroll edge effect; before that it's a plain safe-area inset. @ViewBuilder @@ -44,3 +51,22 @@ private struct SoftScrollEdge: ViewModifier { } } } + +private struct HidesTopScrollEdge: ViewModifier { + + let offset: CGFloat + + @State private var isPastOffset = false + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + .onScrollGeometryChange(for: Bool.self) { $0.contentOffset.y > offset } action: { _, isPast in + isPastOffset = isPast + } + .scrollEdgeEffectHidden(!isPastOffset, for: .top) + } else { + content + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift index dfa0be46f..c84c40f24 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Colors.swift @@ -39,6 +39,8 @@ extension ShapeStyle where Self == Color { public static var backgroundSecondary: Color { Color("backgroundSecondary") } public static var backgroundRow: Color { Color.white.opacity(0.05) } public static var rowSeparator: Color { Color.white.opacity(0.1) } + /// `#323234` — the flat fill behind a profile's cover banner when the profile has no cover picture. + public static var coverPlaceholder: Color { Color(r: 50, g: 50, b: 52) } public static var unreadIndicator: Color { Color(r: 10, g: 132, b: 255) } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift index d91675814..4ab272c6d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift @@ -21,8 +21,11 @@ public struct ContactAvatarView: View { /// The monogram's color. Full-strength by default; a surface whose design mutes the initials /// passes a dimmer one. public let initialsColor: Color + /// Rounds the avatar into a square with this corner radius; `nil` keeps it a circle. + public let cornerRadius: CGFloat? - public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44, initialsColor: Color = .textMain) { + public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44, initialsColor: Color = .textMain, cornerRadius: CGFloat? = nil) { + self.cornerRadius = cornerRadius self.id = id self.displayName = displayName self.imageData = imageData @@ -31,6 +34,14 @@ public struct ContactAvatarView: View { self.initialsColor = initialsColor } + private var clipShape: AnyShape { + if let cornerRadius { + AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } else { + AnyShape(Circle()) + } + } + public var body: some View { Group { if let imageData, @@ -61,7 +72,7 @@ public struct ContactAvatarView: View { } } .frame(width: size, height: size) - .clipShape(Circle()) + .clipShape(clipShape) .accessibilityElement(children: .ignore) .accessibilityLabel(Text(displayName.isEmpty ? "Contact" : displayName)) .accessibilityAddTraits(.isImage) diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift index 84c654034..ed6076ead 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/FloatingToast.swift @@ -151,7 +151,9 @@ private struct FloatingToastTransition: ViewModifier { content.transition(tabBar == nil ? Self.fromScreenEdge : Self.fromTabBar) } - private static let fromScreenEdge: AnyTransition = .move(edge: .bottom).combined(with: .opacity) + /// Rises a fixed distance rather than from the screen edge: a toast raised over a pinned bar would + /// otherwise travel the bar's height too. 56pt is the trip a toast 12pt off the edge always made. + private static let fromScreenEdge: AnyTransition = .offset(y: 56).combined(with: .opacity) /// Starts tucked behind the bar, which draws over the screen's content, and swells up out of it. private static let fromTabBar: AnyTransition = .modifier( diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift index ca0597213..86779f72b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/ToastController.swift @@ -56,6 +56,10 @@ public final class ToastController { didSet { if isCovered { current = nil } } } + /// Extra room the toast keeps above the host's bottom edge, for a bar a screen pins there. Set + /// through ``View/toastClearance(_:)``. + public var bottomClearance: CGFloat = 0 + /// A controller with no toast showing. public init() {} @@ -108,6 +112,36 @@ extension View { } } +extension View { + /// Raises `controller`'s toast above this view, measured, for as long as it is on screen. Put it on + /// a bar a screen pins to the bottom edge, so a toast doesn't draw over it. + public func toastClearance(_ controller: ToastController) -> some View { + modifier(ToastClearance(controller: controller)) + } +} + +private struct ToastClearance: ViewModifier { + let controller: ToastController + @State private var height: CGFloat = 0 + @State private var isVisible = false + + func body(content: Content) -> some View { + content + .onGeometryChange(for: CGFloat.self, of: \.size.height) { newValue in + height = newValue + if isVisible { controller.bottomClearance = newValue } + } + .onAppear { + isVisible = true + controller.bottomClearance = height + } + .onDisappear { + isVisible = false + controller.bottomClearance = 0 + } + } +} + private struct ToastHost: View { let controller: ToastController let bottomPadding: CGFloat @@ -129,7 +163,7 @@ private struct ToastHost: View { onDismiss: toast.action == nil ? nil : { controller.dismiss(toast.id) } ) .allowsHitTesting(toast.action != nil) - .padding(.bottom, bottomPadding) + .padding(.bottom, bottomPadding + controller.bottomClearance) .floatingToastTransition() .id(toast.slot) .task(id: toast.id) { diff --git a/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift b/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift index beab4c6fd..dd50d57e2 100644 --- a/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift +++ b/FlipcashUITests/Regression/ApplicationLogsRegressionTests.swift @@ -18,10 +18,9 @@ final class ApplicationLogsRegressionTests: BaseUITestCase { assertMainScreenReached() - // Navigate: Wallet → You → Advanced → Application Logs + // Navigate: Wallet → You → gear → Application Logs settings.open(from: self) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(settings.applicationLogsRow) + scrollUpToAndTap(settings.applicationLogsRow, in: settings.scrollView) // Verify the Application Logs screen is displayed let title = app.navigationBars["Application Logs"] diff --git a/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift b/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift index 422d948b2..a8b97a9bd 100644 --- a/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift +++ b/FlipcashUITests/Smoke/AccessKeyBackupSmokeTests.swift @@ -19,12 +19,9 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { assertMainScreenReached() - // Navigate: Wallet → You → Advanced → Access Key. The row is on - // Advanced, not My Account — `SettingsMyAccountScreen` keeps the - // account-level actions (Access Key, Log Out, Delete Account) off itself. + // Navigate: Wallet → You → gear → Access Key (Security section). settings.open(from: self) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(settings.accessKeyRow) + scrollUpToAndTap(settings.accessKeyRow, in: settings.scrollView) // Confirmation dialog: "View Your Access Key?" let dialog = app.otherElements["View Your Access Key?"] @@ -44,10 +41,9 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { assertMainScreenReached() - // Wallet → You → Advanced → Access Key. + // Wallet → You → gear → Access Key. settings.open(from: self) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(settings.accessKeyRow) + scrollUpToAndTap(settings.accessKeyRow, in: settings.scrollView) let dialog = app.otherElements["View Your Access Key?"] XCTAssertTrue(dialog.waitForExistence(timeout: 10), "Expected access key confirmation dialog") @@ -77,10 +73,9 @@ final class AccessKeyBackupSmokeTests: BaseUITestCase { assertMainScreenReached() - // Wallet → You → Advanced → Access Key. + // Wallet → You → gear → Access Key. settings.open(from: self) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(settings.accessKeyRow) + scrollUpToAndTap(settings.accessKeyRow, in: settings.scrollView) let dialog = app.otherElements["View Your Access Key?"] XCTAssertTrue(dialog.waitForExistence(timeout: 10), "Expected access key confirmation dialog") diff --git a/FlipcashUITests/Smoke/BetaAccessUnlockSmokeTests.swift b/FlipcashUITests/Smoke/BetaAccessUnlockSmokeTests.swift index 2d710b29a..1be0f3e93 100644 --- a/FlipcashUITests/Smoke/BetaAccessUnlockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BetaAccessUnlockSmokeTests.swift @@ -38,24 +38,20 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { "Expected the tenth tap to say beta access is unlocked" ) - settings.navigateToAdvancedFeatures(from: self) XCTAssertTrue( settings.switchAccountsRow.waitForExistence(timeout: 10), - "Expected Switch Accounts on Advanced once beta access is unlocked" + "Expected Switch Accounts on Settings once beta access is unlocked" ) - settings.leaveAdvancedFeatures(from: self) - settings.tapVersionFooter(10, from: self) XCTAssertTrue( settings.versionToast("Beta features are hidden again").waitForExistence(timeout: 5), "Expected ten more taps to lock beta access again" ) - settings.navigateToAdvancedFeatures(from: self) XCTAssertTrue( settings.accessKeyRow.waitForExistence(timeout: 10), - "Expected Advanced to be on screen" + "Expected Settings to be on screen" ) XCTAssertFalse( settings.switchAccountsRow.exists, @@ -63,7 +59,7 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { ) } - /// Beta Features is reached through Advanced, one level past what the unlock + /// Beta Features is reached from Settings, one level past what the unlock /// test above visits. Confirms the Contracts section — new rows this repo /// didn't have before — renders once developer access is on. func testBetaFeatures_showsContractVersionsAfterUnlock() throws { @@ -79,8 +75,7 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { "Expected the tenth tap to say beta access is unlocked" ) - settings.navigateToAdvancedFeatures(from: self) - waitAndTap(app.buttons["Beta Features"]) + scrollUpToAndTap(app.buttons["Beta Features"], in: settings.scrollView) // Row wraps its content in a Button, so the combined accessibility // element carries the Button trait despite having no tap action. @@ -91,8 +86,6 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { XCTAssertTrue(flipcash2Row.exists) waitAndTap(app.navigationBars["Beta Features"].buttons.firstMatch) - settings.leaveAdvancedFeatures(from: self) - settings.tapVersionFooter(10, from: self) XCTAssertTrue( settings.versionToast("Beta features are hidden again").waitForExistence(timeout: 5), @@ -107,10 +100,7 @@ final class BetaAccessUnlockSmokeTests: BaseUITestCase { /// itself says nothing until it is tapped, and a tap is the thing under /// test. private func lockBetaAccessIfUnlocked(_ settings: SettingsUIScreen) { - settings.navigateToAdvancedFeatures(from: self) let isUnlocked = settings.switchAccountsRow.waitForExistence(timeout: 5) - settings.leaveAdvancedFeatures(from: self) - guard isUnlocked else { return } settings.tapVersionFooter(10, from: self) diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift index 7be60b3f8..8395481fb 100644 --- a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift @@ -77,7 +77,8 @@ final class BlockUnblockSmokeTests: BaseUITestCase { titleButton.tap() // MARK: Block from the profile screen. - waitAndTap(app.buttons["Block"], timeout: 30, "Expected the Block row on the profile screen") + waitAndTap(app.buttons["profile-overflow"], timeout: 30, "Expected the overflow menu on the profile screen") + waitAndTap(app.buttons["Block"], timeout: 10, "Expected Block in the profile's overflow menu") let blockDialog = app.otherElements .matching(NSPredicate(format: "identifier BEGINSWITH %@", "Block ")) .firstMatch @@ -95,10 +96,9 @@ final class BlockUnblockSmokeTests: BaseUITestCase { "Expected '\(name)' tip conversation to disappear from the Chat tab after blocking" ) - // MARK: Unblock from You › My Account › Blocked. + // MARK: Unblock from You › Settings › Blocked. settings.open(from: self) - settings.navigateToMyAccount(from: self) - waitAndTap(settings.blockedRow) + scrollUpToAndTap(settings.blockedRow, in: settings.scrollView) blocked.assertLoaded(from: self) let blockedRow = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", name)).firstMatch @@ -135,7 +135,7 @@ final class BlockUnblockSmokeTests: BaseUITestCase { try await super.tearDown() } - /// Navigates You › My Account › Blocked and unblocks `name` if present, + /// Navigates You › Settings › Blocked and unblocks `name` if present, /// tolerating every step so a failed test's teardown stays quiet. It cannot /// reuse `SettingsUIScreen`'s navigation helpers: those assert, and a /// teardown assertion would mask the failure that brought us here. @@ -144,8 +144,8 @@ final class BlockUnblockSmokeTests: BaseUITestCase { let you = app.tabBars.buttons["You"].firstMatch guard you.waitForExistence(timeout: 30) else { return } you.tap() - guard scrollTo(settings.myAccountRow, in: settings.scrollView) else { return } - settings.myAccountRow.tap() + guard settings.gear.waitForExistence(timeout: 30) else { return } + settings.gear.tap() guard scrollTo(settings.blockedRow, in: settings.scrollView) else { return } settings.blockedRow.tap() diff --git a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift index 9401e86d4..7179000ca 100644 --- a/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockedUsersSmokeTests.swift @@ -5,7 +5,7 @@ import XCTest -/// Smoke tests for the Blocked users list reached from the You tab › My Account +/// Smoke tests for the Blocked users list reached from the You tab › Settings /// › Blocked. /// /// **Scope.** These cover the navigation into the list and that the screen loads @@ -23,18 +23,17 @@ final class BlockedUsersSmokeTests: BaseUITestCase { override var requiresAuthentication: Bool { true } - /// Wallet → You → My Account → Blocked lands on the Blocked list and it + /// Wallet → You → Settings → Blocked lands on the Blocked list and it /// loads its state (empty or populated) without hanging. - func testBlockedUsers_reachableFromMyAccount() { + func testBlockedUsers_reachableFromSettings() { let settings = SettingsUIScreen(app: app) let blocked = BlockedUsersUIScreen(app: app) assertMainScreenReached() settings.open(from: self) - settings.navigateToMyAccount(from: self) - waitAndTap(settings.blockedRow) + scrollUpToAndTap(settings.blockedRow, in: settings.scrollView) blocked.assertLoaded(from: self) } diff --git a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift index 9b17c9816..c3d6a53cf 100644 --- a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift +++ b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift @@ -7,7 +7,7 @@ import XCTest /// Covers the display name and the tip card it earns: a freshly registered /// account lands on the You tab with a card it can share, and the name behind -/// that card can be changed from My Account. +/// that card can be changed from Edit Profile. /// /// This replaces the old profile-creation walkthrough (Tips intro → name → /// photo → tipcard), which has no subject left. The name step is mandatory @@ -37,23 +37,23 @@ final class DisplayNameSmokeTests: BaseUITestCase { // arrives already tippable. createFreshAccount() - // MARK: The card is there, with its actions. - settings.open(from: self) + // MARK: The profile is there, with its actions. + settings.openYouTab(from: self) XCTAssertTrue( - app.buttons["you-share-button"].waitForExistence(timeout: 30), - "Expected the tip card's Share action on a named account. On screen: [\(visibleText())]" + app.buttons["you-share"].waitForExistence(timeout: 30), + "Expected the profile's Share menu on a named account. On screen: [\(visibleText())]" ) XCTAssertTrue( - app.buttons["you-download-button"].exists, - "Expected the tip card's Download action alongside Share" + app.staticTexts["profile-name"].exists, + "Expected the profile header's name, not the name-less setup prompt" ) XCTAssertTrue( - app.buttons["you-fullscreen-button"].exists, - "Expected the card itself, not the name-less setup prompt" + app.buttons["you-edit-profile"].exists, + "Expected the Edit Profile button under the stats" ) - // MARK: Change the name from My Account. - settings.navigateToMyAccount(from: self) + // MARK: Change the name from Edit Profile. + waitAndTap(settings.editProfileButton) waitAndTap(settings.displayNameRow) let save = app.buttons["profile-name-next-button"] @@ -101,23 +101,23 @@ final class DisplayNameSmokeTests: BaseUITestCase { ) // `ProfileNameScreen(completion: .back)` pops itself only once - // `SetDisplayName` returns, so landing back on My Account is proof the + // `SetDisplayName` returns, so landing back on Edit Profile is proof the // new name was accepted and moderated — a rejection keeps the editor up - // behind a dialog. It pops just the one screen, so this is My Account - // rather than the You tab root. + // behind a dialog. XCTAssertTrue( settings.displayNameRow.waitForExistence(timeout: 60), - "Expected the name editor to pop back to My Account once the name saved. On screen: [\(visibleText())]" + "Expected the name editor to pop back to Edit Profile once the name saved. On screen: [\(visibleText())]" ) - // MARK: The card survives the rename. + // MARK: The profile survives the rename. // Unwind the last screen by hand: the tab bar stays hidden while the // You tab has a stack, so there is no You button to tap back to. + // Edit Profile is one push from the root; a second tap would land on the gear. waitAndTap(app.navigationBars.buttons.firstMatch) - assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of My Account") + assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of Edit Profile") XCTAssertTrue( - app.buttons["you-share-button"].waitForExistence(timeout: 30), - "Expected the tip card still on the You tab after renaming" + app.buttons["you-share"].waitForExistence(timeout: 30), + "Expected the profile still on the You tab after renaming" ) } } diff --git a/FlipcashUITests/Smoke/LoginSmokeTests.swift b/FlipcashUITests/Smoke/LoginSmokeTests.swift index 369031399..073d2525c 100644 --- a/FlipcashUITests/Smoke/LoginSmokeTests.swift +++ b/FlipcashUITests/Smoke/LoginSmokeTests.swift @@ -24,11 +24,11 @@ final class LoginSmokeTests: BaseUITestCase { // Verify we're on the main screen assertMainScreenReached() - // Settings lives on the You tab now: You → Advanced → Log Out. The - // settings rows sit under the tip card, so scroll them into view. + // Settings is behind the gear on the You tab: You → gear → Log Out. + // Log Out sits near the foot of the list, so scroll it into view. waitAndTap(app.tabBars.buttons["You"].firstMatch) - scrollUpToAndTap(app.buttons["Advanced"], in: app.scrollViews.firstMatch) - waitAndTap(app.buttons["Log Out"]) + waitAndTap(app.buttons["you-settings"]) + scrollUpToAndTap(app.buttons["Log Out"], in: app.scrollViews.firstMatch) // Confirmation dialog — scoped to the dialog container let dialog = app.otherElements["Are You Sure You Want To Log Out?"] diff --git a/FlipcashUITests/Smoke/ProfileCardSmokeTests.swift b/FlipcashUITests/Smoke/ProfileCardSmokeTests.swift new file mode 100644 index 000000000..ba9375611 --- /dev/null +++ b/FlipcashUITests/Smoke/ProfileCardSmokeTests.swift @@ -0,0 +1,38 @@ +// +// ProfileCardSmokeTests.swift +// FlipcashUITests +// + +import XCTest + +/// Covers the profile card the You tab's Share menu shows over the tab, and its Close button. +/// +/// **Prerequisites:** the `FLIPCASH_UI_TEST_ACCESS_KEY` account needs a display +/// name — without one the tab has no Share menu and no card to show. +@MainActor +final class ProfileCardSmokeTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + /// Share → Show Profile Card presents the card with its Download action, and Close returns. + func testShowProfileCard_opensAndCloses() throws { + assertMainScreenReached() + waitAndTap(app.tabBars.buttons["You"].firstMatch) + waitAndTap(app.buttons["you-share"]) + waitAndTap(app.buttons["Show Profile Card"]) + + XCTAssertTrue( + app.buttons["you-download-button"].waitForExistence(timeout: 10), + "Expected the profile card with its Download action" + ) + + waitAndTap(app.buttons["profile-card-close"]) + + // The card draws over the tab, so the tab's own buttons stay in the tree while it is up. The + // toolbar button reads Settings again only once the card is gone. + XCTAssertTrue( + app.buttons["you-settings"].waitForExistence(timeout: 10), + "Expected Close to return to the You tab, with the gear back in place of Download" + ) + } +} diff --git a/FlipcashUITests/Smoke/TipCardFullScreenSmokeTests.swift b/FlipcashUITests/Smoke/TipCardFullScreenSmokeTests.swift deleted file mode 100644 index 91f6b0bbd..000000000 --- a/FlipcashUITests/Smoke/TipCardFullScreenSmokeTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// TipCardFullScreenSmokeTests.swift -// FlipcashUITests -// - -import XCTest - -/// Covers the You tab's full-screen tip card and the pull that puts it back: -/// up closes it, down does not. -/// -/// **Prerequisites:** the `FLIPCASH_UI_TEST_ACCESS_KEY` account needs a display -/// name — without one the tab shows the add-your-name invitation and there is -/// no card to expand. -@MainActor -final class TipCardFullScreenSmokeTests: BaseUITestCase { - - override var requiresAuthentication: Bool { true } - - /// Pulling the expanded card up toward its slot puts it back, no close tap. - func testExpandedTipCard_pullUp_collapses() throws { - let closeButton = expandTipCard() - - pullCard(from: 0.6, to: 0.25) - - XCTAssertTrue( - closeButton.waitForNonExistence(timeout: 10), - "Expected the pull up to put the card back" - ) - } - - /// Down is where expanding already took the card, so a downward pull is - /// resisted rather than read as a dismissal. - func testExpandedTipCard_pullDown_staysExpanded() throws { - let closeButton = expandTipCard() - - pullCard(from: 0.4, to: 0.75) - - XCTAssertTrue(closeButton.exists, "Expected the card to stay expanded") - } - - // MARK: - Helpers - - - /// Opens the card full screen and returns its close control. - private func expandTipCard() -> XCUIElement { - assertMainScreenReached() - waitAndTap(app.tabBars.buttons["You"].firstMatch) - waitAndTap(app.buttons["you-fullscreen-button"]) - - let closeButton = app.buttons["you-close-fullscreen-button"] - XCTAssertTrue(closeButton.waitForExistence(timeout: 10), "Expected the card to expand") - return closeButton - } - - /// Drags the middle of the screen between two normalized heights — where - /// the expanded card is, and slowly enough that only the distance counts, - /// never a flick's projection. - private func pullCard(from start: CGFloat, to end: CGFloat) { - let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: start)) - let destination = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: end)) - origin.press(forDuration: 0.2, thenDragTo: destination) - } -} diff --git a/FlipcashUITests/Smoke/TipsSmokeTests.swift b/FlipcashUITests/Smoke/TipsSmokeTests.swift index c13007867..bd1f0e95d 100644 --- a/FlipcashUITests/Smoke/TipsSmokeTests.swift +++ b/FlipcashUITests/Smoke/TipsSmokeTests.swift @@ -5,7 +5,7 @@ import XCTest -/// Rides a tipcard link from the link to the chat it opens, without committing +/// Rides a tipcard link from the link to the profile it opens, without committing /// a transfer. The tipcard deeplink stands in for scanning, which a simulator /// camera can't do. /// @@ -33,19 +33,18 @@ final class TipsSmokeTests: BaseUITestCase { executionTimeAllowance = 600 } - /// A tipcard link shows the recipient's card and then lands the sender in - /// the chat with them, where the amount is chosen. The chat doesn't exist - /// until the first tip, so what proves the landing is the send control the - /// empty thread offers — no message list, no transfer. - func testTipDeeplink_opensTheChat() throws { + /// A tipcard link shows the recipient's card and then opens their + /// profile, whose pinned button starts or opens the chat. The profile + /// proves the landing; no transfer is made. + func testTipDeeplink_opensTheProfile() throws { assertMainScreenReached() app.open(URL(string: "flipcash://tip/\(Self.recipientID)")!) - let sendButton = app.buttons["send-cash-button"] + let pinnedAction = app.buttons["profile-pinned-action"] XCTAssertTrue( - sendButton.waitForExistence(timeout: 180), - "The tip chat never appeared. On screen: [\(visibleText())]" + pinnedAction.waitForExistence(timeout: 180), + "The recipient's profile never appeared. On screen: [\(visibleText())]" ) } } diff --git a/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift b/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift index f2844d822..d19ceef80 100644 --- a/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift +++ b/FlipcashUITests/Support/Screens/BlockedUsersUIScreen.swift @@ -5,7 +5,7 @@ import XCTest -/// Page object for `BlockedUsersScreen` — the Settings › My Account › Blocked +/// Page object for `BlockedUsersScreen` — the Settings › Blocked /// list. @MainActor struct BlockedUsersUIScreen { diff --git a/FlipcashUITests/Support/Screens/SettingsScreen.swift b/FlipcashUITests/Support/Screens/SettingsScreen.swift index e24c53fdd..7ecf70475 100644 --- a/FlipcashUITests/Support/Screens/SettingsScreen.swift +++ b/FlipcashUITests/Support/Screens/SettingsScreen.swift @@ -7,10 +7,9 @@ import XCTest /// Page object for the settings list and its sub-screens. /// -/// The tab-bar UI has no Settings sheet: `YouScreen` renders the list inline -/// under the tip card, so "open settings" is the You tab plus a scroll. Only -/// **My Account** and **Advanced** live here now — Add Money and Withdraw Money -/// moved to the Wallet tab's tiles, on `WalletScreen`. +/// Settings is pushed from the gear on the You tab, so "open settings" is the +/// You tab plus the gear. Add Money and Withdraw Money live on the Wallet tab's +/// tiles, on `WalletScreen`. @MainActor struct SettingsUIScreen { @@ -22,25 +21,29 @@ struct SettingsUIScreen { // MARK: - Elements - /// The You tab's scrolling content, the container the rows are scrolled in. + /// The Settings list's scrolling content, the container the rows are scrolled in. var scrollView: XCUIElement { app.scrollViews.firstMatch } - var myAccountRow: XCUIElement { app.buttons["My Account"] } - var advancedFeaturesRow: XCUIElement { app.buttons["Advanced"] } + /// The gear on the You tab that pushes Settings. + var gear: XCUIElement { app.buttons["you-settings"] } + var accessKeyRow: XCUIElement { app.buttons["Access Key"] } var applicationLogsRow: XCUIElement { app.buttons["Application Logs"] } - /// The My Account row that opens the display-name editor. - var displayNameRow: XCUIElement { app.buttons["Display Name"] } + /// The You tab button that opens Edit Profile. + var editProfileButton: XCUIElement { app.buttons["you-edit-profile"] } + + /// Edit Profile's row that opens the display-name editor. + var displayNameRow: XCUIElement { app.buttons["edit-profile-name"] } - /// The My Account row that opens the Blocked list. + /// The Privacy row that opens the Blocked list. var blockedRow: XCUIElement { app.buttons["Blocked"] } - /// The Advanced row that opens the account switcher — drawn only once the + /// The Advanced-section row that opens the account switcher — drawn only once the /// version footer has unlocked beta access. var switchAccountsRow: XCUIElement { app.buttons["account-switch-accounts-row"] } - /// The version string at the foot of the You tab, which doubles as the + /// The version string at the foot of Settings, which doubles as the /// beta-access easter egg. var versionFooter: XCUIElement { app.buttons["you-version-footer"] } @@ -63,16 +66,15 @@ struct SettingsUIScreen { // MARK: - Actions - /// Opens the You tab, which hosts the settings list. - func open(from testCase: BaseUITestCase) { + /// Opens the You tab. + func openYouTab(from testCase: BaseUITestCase) { testCase.waitAndTap(app.tabBars.buttons["You"].firstMatch) } - /// Navigates to My Account sub-screen. The rows render below the tip card, - /// so they start off-screen and have to be scrolled to rather than tapped - /// blind. - func navigateToMyAccount(from testCase: BaseUITestCase) { - testCase.scrollUpToAndTap(myAccountRow, in: scrollView) + /// Opens Settings: the You tab, then the gear. + func open(from testCase: BaseUITestCase) { + openYouTab(from: testCase) + testCase.waitAndTap(gear) } /// Taps the version footer `times` times, scrolling it into view first. @@ -83,19 +85,4 @@ struct SettingsUIScreen { versionFooter.tap() } } - - /// Pops the My Account screen back to the You tab. - func leaveMyAccount(from testCase: BaseUITestCase) { - testCase.waitAndTap(app.navigationBars["My Account"].buttons.firstMatch) - } - - /// Navigates to Advanced Features sub-screen. - func navigateToAdvancedFeatures(from testCase: BaseUITestCase) { - testCase.scrollUpToAndTap(advancedFeaturesRow, in: scrollView) - } - - /// Pops the Advanced screen back to the You tab. - func leaveAdvancedFeatures(from testCase: BaseUITestCase) { - testCase.waitAndTap(app.navigationBars["Advanced"].buttons.firstMatch) - } }