From 486c8c233b8b0acb8ad053543dab994583bc4031 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 07:30:32 -0400 Subject: [PATCH 01/15] feat(profile): Edit Profile with bio and cover --- .../Navigation/AppRouter+Destination.swift | 15 +- .../AppRouter+DestinationView.swift | 16 ++ .../Profile/ChangeCoverPictureScreen.swift | 205 ++++++++++++++++++ .../Screens/Main/Profile/EditBioModel.swift | 127 +++++++++++ .../Screens/Main/Profile/EditBioScreen.swift | 104 +++++++++ .../Main/Profile/EditProfileScreen.swift | 179 +++++++++++++++ .../Main/Profile/ProfileCreationState.swift | 2 +- .../Main/Profile/ProfilePhotoScreen.swift | 3 +- .../Profile/ProfilePictureUploading.swift | 38 +++- .../Screens/Settings/SettingsScreen.swift | 27 +-- FlipcashTests/EditBioModelTests.swift | 135 ++++++++++++ .../Navigation/SettingsRoutingTests.swift | 19 +- FlipcashTests/ProfileCreationStateTests.swift | 21 +- .../Smoke/DisplayNameSmokeTests.swift | 6 +- .../Support/Screens/SettingsScreen.swift | 7 +- 15 files changed, 867 insertions(+), 37 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Profile/EditBioModel.swift create mode 100644 Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift create mode 100644 FlipcashTests/EditBioModelTests.swift diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index e64d65f6f..b032524ad 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -60,6 +60,12 @@ extension AppRouter { 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 Settings. + case editProfile + /// The bio on its own, pushed from Edit Profile. + case editBio + /// 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. @@ -143,7 +149,8 @@ extension AppRouter { .buyCurrency, .convertCurrency, .withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress: return .balance - case .settings, .accountInfo, .changeDisplayName, .changeProfilePicture, .username, + case .settings, .accountInfo, .editProfile, .editBio, .changeCoverPicture, + .changeDisplayName, .changeProfilePicture, .username, .setMinimumTip, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw: @@ -180,6 +187,9 @@ extension AppRouter { case .usdcDepositAddress: "usdcDepositAddress" case .settings: "settings" case .accountInfo: "accountInfo" + case .editProfile: "editProfile" + case .editBio: "editBio" + case .changeCoverPicture: "changeCoverPicture" case .changeDisplayName: "changeDisplayName" case .changeProfilePicture: "changeProfilePicture" case .username: "username" @@ -245,7 +255,8 @@ extension AppRouter { case .activity, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .usdcDepositEducation, .usdcDepositAddress, - .settings, .accountInfo, .changeDisplayName, .changeProfilePicture, + .settings, .accountInfo, .editProfile, .editBio, .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 0951a5d72..69d74a4f1 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -87,6 +87,22 @@ struct DestinationView: View { 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 .changeCoverPicture: + ChangeCoverPictureScreen() + case .changeDisplayName: ChangeDisplayNameScreen(currentName: sessionContainer.session.profile?.displayName ?? "") diff --git a/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift new file mode 100644 index 000000000..2feb75da8 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift @@ -0,0 +1,205 @@ +// +// ChangeCoverPictureScreen.swift +// Flipcash +// + +import SwiftUI +import UniformTypeIdentifiers +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.profile-cover") + +/// A 3:1 banner: the image when there is one, otherwise a flat fill in the profile-card colour. +struct CoverBanner: View { + + /// The cover to draw, or nil to draw ``fill``. + let image: UIImage? + /// What an empty cover is filled with. + let fill: Color + + var body: some View { + Color.clear + .aspectRatio(3, contentMode: .fit) + .overlay { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + fill + } + } + .clipped() + } + + /// The fill for an empty cover: the card colour the user chose, or the card tone when unset. + static func fill(for profile: Profile?) -> Color { + profile?.tipCardCustomization.flatMap { Color(hex: $0.colorHex) } ?? Color.backgroundRow + } +} + +/// 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 } + + private var currentCover: UIImage? { + sessionContainer.profileAvatars + .data(for: .cover(session.userID)) + .flatMap(UIImage.init(data:)) + } + + 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: { + CoverBanner(image: state.selectedImage ?? currentCover, fill: CoverBanner.fill(for: profile)) + .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius)) + .overlay { + if state.selectedImage == nil && currentCover == nil { + Image(systemName: "plus") + .font(.system(size: 40, weight: .light)) + .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() + } + // Keyed on the blob so the cover this screen just uploaded is what the banner ends up drawing. + .task(id: profile?.coverPicture?.blobID) { + await sessionContainer.profileAvatars.load(.cover(session.userID), picture: profile?.coverPicture) + } + } + + 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..71c5a4463 --- /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) + .onAppear { isFocused = true } + .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/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift new file mode 100644 index 000000000..31c48b53f --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -0,0 +1,179 @@ +// +// EditProfileScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The signed-in user's profile fields in one list: cover, photo, then a row per field showing +/// its current value. Pushed from Settings. +struct EditProfileScreen: View { + + @Environment(AppRouter.self) private var router + @Environment(SessionContainer.self) private var sessionContainer + + @State private var dialog: DialogItem? + + private static let avatarSize: CGFloat = 96 + private static let rowInsets = EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20) + + private var session: Session { sessionContainer.session } + private var profile: Profile? { session.profile } + + private var avatarImage: UIImage? { + sessionContainer.profileAvatars + .data(for: session.userID) + .flatMap(UIImage.init(data:)) + } + + private var coverImage: UIImage? { + sessionContainer.profileAvatars + .data(for: .cover(session.userID)) + .flatMap(UIImage.init(data:)) + } + + /// 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) { + header + fieldRows + } + .padding(.bottom, 24) + } + } + .navigationTitle("Edit Profile") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .task(id: profile?.coverPicture?.blobID) { + await sessionContainer.profileAvatars.load(.cover(session.userID), picture: profile?.coverPicture) + } + .task(id: profile?.profilePicture?.thumbnailBlobID) { + await sessionContainer.profileAvatars.load(userID: session.userID, picture: profile?.profilePicture) + } + } + + // MARK: - Header - + + private var header: some View { + ZStack(alignment: .bottomLeading) { + Button { + router.push(.changeCoverPicture) + } label: { + CoverBanner(image: coverImage, fill: CoverBanner.fill(for: profile)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-profile-cover") + .padding(.bottom, Self.avatarSize / 2) + + Button { + router.push(.changeProfilePicture) + } label: { + CircleImage(image: avatarImage, size: Self.avatarSize, plusSize: 36) + .overlay(Circle().stroke(Color.backgroundMain, lineWidth: 4)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-profile-photo") + .padding(.leading, 20) + } + .padding(.bottom, 8) + } + + // MARK: - Rows - + + @ViewBuilder + private var fieldRows: some View { + FieldRow(title: "Display Name", value: nonEmpty(profile?.displayName), placeholder: "Add a name", insets: Self.rowInsets) { + router.push(.changeDisplayName) + } + .accessibilityIdentifier("edit-profile-name") + + usernameRow + + FieldRow(title: "Bio", value: nonEmpty(profile?.bio), placeholder: "Add a bio", insets: Self.rowInsets) { + router.push(.editBio) + } + .accessibilityIdentifier("edit-profile-bio") + + FieldRow(title: "Minimum to Chat", value: minimumToChat, placeholder: "Not set", insets: Self.rowInsets) { + router.push(.setMinimumTip(isSetupStep: false)) + } + .accessibilityIdentifier("edit-profile-minimum") + } + + @ViewBuilder + private var usernameRow: some View { + if needsUsernameClaim { + FieldRow(title: "Username", value: "Claim your username", placeholder: "", insets: Self.rowInsets, 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. + FieldRow(title: "Username", value: "@\(username.value)", placeholder: "", insets: Self.rowInsets) { + router.push(.username(username)) + } + .accessibilityIdentifier("edit-profile-username") + } + } + + private var minimumToChat: String? { + profile?.minDmChatInitFee?.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 row with the field's title and its current value, one line, trailing. +private struct FieldRow: View { + + let title: String + let value: String? + let placeholder: String + let insets: EdgeInsets + var valueIsPrompt = false + let action: VoidAction + + var body: some View { + Row(insets: insets, accessory: .chevron) { + Text(title) + .font(.appDisplayXS) + .foregroundStyle(Color.textMain) + Spacer(minLength: 12) + Text(value ?? placeholder) + .font(.appTextMedium) + .foregroundStyle(value == nil || valueIsPrompt ? Color.textSecondary : Color.textMain) + .lineLimit(1) + .truncationMode(.tail) + } action: { + action() + } + } +} 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/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/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index 2b1c0e785..bc0f60502 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -56,34 +56,15 @@ struct SettingsScreen: View { // MARK: - Sections - - /// Interim: Edit Profile replaces this section. + /// Interim: Slice 3 removes this row once the You tab has its own Edit Profile button. private var profileSection: some View { VStack(alignment: .leading, spacing: 0) { SettingsSectionHeader("Profile") - SettingsRow(systemImage: "person.text.rectangle", title: "Display Name", insets: insets) { - router.push(.changeDisplayName) + SettingsRow(systemImage: "person.text.rectangle", title: "Edit Profile", insets: insets) { + router.push(.editProfile) } - - // 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") + .accessibilityIdentifier("settings-edit-profile") } } 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/Navigation/SettingsRoutingTests.swift b/FlipcashTests/Navigation/SettingsRoutingTests.swift index 72ef20b3d..e16994558 100644 --- a/FlipcashTests/Navigation/SettingsRoutingTests.swift +++ b/FlipcashTests/Navigation/SettingsRoutingTests.swift @@ -10,9 +10,12 @@ import Testing @Suite("Settings routing") struct SettingsRoutingTests { - @Test("Settings and Account Info are owned by the You stack", arguments: [ + @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.changeCoverPicture, ]) func owningStack(destination: AppRouter.Destination) { #expect(destination.owningStack == .you) @@ -28,6 +31,20 @@ struct SettingsRoutingTests { #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") 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/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift index 6627dee1b..9f0e37f22 100644 --- a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift +++ b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift @@ -54,6 +54,7 @@ final class DisplayNameSmokeTests: BaseUITestCase { // MARK: Change the name from Settings. waitAndTap(settings.gear) + waitAndTap(settings.editProfileRow) waitAndTap(settings.displayNameRow) let save = app.buttons["profile-name-next-button"] @@ -103,17 +104,18 @@ final class DisplayNameSmokeTests: BaseUITestCase { // `ProfileNameScreen(completion: .back)` pops itself only once // `SetDisplayName` returns, so landing back on Settings 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 Settings + // behind a dialog. It pops just the one screen, so this is Edit Profile // rather than the You tab root. XCTAssertTrue( settings.displayNameRow.waitForExistence(timeout: 60), - "Expected the name editor to pop back to Settings 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 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. waitAndTap(app.navigationBars.buttons.firstMatch) + waitAndTap(app.navigationBars.buttons.firstMatch) assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of Settings") XCTAssertTrue( app.buttons["you-share"].waitForExistence(timeout: 30), diff --git a/FlipcashUITests/Support/Screens/SettingsScreen.swift b/FlipcashUITests/Support/Screens/SettingsScreen.swift index 512cc8786..db7d40556 100644 --- a/FlipcashUITests/Support/Screens/SettingsScreen.swift +++ b/FlipcashUITests/Support/Screens/SettingsScreen.swift @@ -30,8 +30,11 @@ struct SettingsUIScreen { var accessKeyRow: XCUIElement { app.buttons["Access Key"] } var applicationLogsRow: XCUIElement { app.buttons["Application Logs"] } - /// The Profile row that opens the display-name editor. - var displayNameRow: XCUIElement { app.buttons["Display Name"] } + /// The Profile row that opens Edit Profile. + var editProfileRow: XCUIElement { app.buttons["settings-edit-profile"] } + + /// Edit Profile's row that opens the display-name editor. + var displayNameRow: XCUIElement { app.buttons["edit-profile-name"] } /// The Privacy row that opens the Blocked list. var blockedRow: XCUIElement { app.buttons["Blocked"] } From 096c9851786824c1e4ad17ad7f30815bb227b57a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:06:27 -0400 Subject: [PATCH 02/15] refactor(profile): open Edit Profile from the You tab and reuse the profile header The You tab's Edit Profile button now pushes Edit Profile instead of Settings, and Settings drops its interim Profile section. Edit Profile draws its cover with ProfileCoverBanner and its avatar at the You tab's 84pt with the same 5pt ring, so the two screens show the same empty cover. The parked CoverBanner, with its 3:1 card-colour fill, is removed; the cover editor previews a picked image through ProfileCoverBanner's new preview parameter. Row insets match Settings (25/20), values sit against the chevron, and the empty-cover plus uses appDisplayMedium instead of a 40pt system font. --- .../Navigation/AppRouter+Destination.swift | 2 +- .../Profile/ChangeCoverPictureScreen.swift | 59 ++++--------------- .../Main/Profile/EditProfileScreen.swift | 44 ++++++-------- .../Core/Screens/Main/You/YouScreen.swift | 3 +- .../Screens/Profile/ProfileCoverBanner.swift | 14 ++++- .../Screens/Profile/ProfileHeaderView.swift | 4 +- .../Screens/Settings/SettingsScreen.swift | 13 ---- .../Smoke/DisplayNameSmokeTests.swift | 5 +- .../Support/Screens/SettingsScreen.swift | 4 +- 9 files changed, 51 insertions(+), 97 deletions(-) diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index b032524ad..ca52e943d 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -60,7 +60,7 @@ extension AppRouter { 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 Settings. + /// 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 diff --git a/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift index 2feb75da8..17894f688 100644 --- a/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift @@ -10,35 +10,6 @@ import FlipcashUI private let logger = Logger(label: "flipcash.profile-cover") -/// A 3:1 banner: the image when there is one, otherwise a flat fill in the profile-card colour. -struct CoverBanner: View { - - /// The cover to draw, or nil to draw ``fill``. - let image: UIImage? - /// What an empty cover is filled with. - let fill: Color - - var body: some View { - Color.clear - .aspectRatio(3, contentMode: .fit) - .overlay { - if let image { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - fill - } - } - .clipped() - } - - /// The fill for an empty cover: the card colour the user chose, or the card tone when unset. - static func fill(for profile: Profile?) -> Color { - profile?.tipCardCustomization.flatMap { Color(hex: $0.colorHex) } ?? Color.backgroundRow - } -} - /// 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 { @@ -56,12 +27,6 @@ struct ChangeCoverPictureScreen: View { private var session: Session { sessionContainer.session } private var profile: Profile? { session.profile } - private var currentCover: UIImage? { - sessionContainer.profileAvatars - .data(for: .cover(session.userID)) - .flatMap(UIImage.init(data:)) - } - var body: some View { Background(color: .backgroundMain) { VStack(spacing: 0) { @@ -69,15 +34,19 @@ struct ChangeCoverPictureScreen: View { Button("Photo Library", systemImage: "photo.on.rectangle") { isShowingPhotoPicker = true } Button("Choose File", systemImage: "folder") { isShowingFilePicker = true } } label: { - CoverBanner(image: state.selectedImage ?? currentCover, fill: CoverBanner.fill(for: profile)) - .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius)) - .overlay { - if state.selectedImage == nil && currentCover == nil { - Image(systemName: "plus") - .font(.system(size: 40, weight: .light)) - .foregroundStyle(Color.textSecondary) - } + 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) @@ -117,10 +86,6 @@ struct ChangeCoverPictureScreen: View { guard state.hasPendingUpload else { return } await upload() } - // Keyed on the blob so the cover this screen just uploaded is what the banner ends up drawing. - .task(id: profile?.coverPicture?.blobID) { - await sessionContainer.profileAvatars.load(.cover(session.userID), picture: profile?.coverPicture) - } } private func submit() { diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index 31c48b53f..d00e1bb0d 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -8,7 +8,7 @@ import FlipcashCore import FlipcashUI /// The signed-in user's profile fields in one list: cover, photo, then a row per field showing -/// its current value. Pushed from Settings. +/// its current value. Pushed from the You tab's Edit Profile button. struct EditProfileScreen: View { @Environment(AppRouter.self) private var router @@ -16,24 +16,11 @@ struct EditProfileScreen: View { @State private var dialog: DialogItem? - private static let avatarSize: CGFloat = 96 - private static let rowInsets = EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20) + private static let rowInsets = EdgeInsets(top: 25, leading: 20, bottom: 25, trailing: 20) private var session: Session { sessionContainer.session } private var profile: Profile? { session.profile } - private var avatarImage: UIImage? { - sessionContainer.profileAvatars - .data(for: session.userID) - .flatMap(UIImage.init(data:)) - } - - private var coverImage: UIImage? { - sessionContainer.profileAvatars - .data(for: .cover(session.userID)) - .flatMap(UIImage.init(data:)) - } - /// Whether the handle still needs claiming: none yet, or one the server assigned. private var needsUsernameClaim: Bool { usernameNeedsClaim( @@ -55,9 +42,6 @@ struct EditProfileScreen: View { .navigationTitle("Edit Profile") .toolbarTitleDisplayMode(.inline) .dialog(item: $dialog) - .task(id: profile?.coverPicture?.blobID) { - await sessionContainer.profileAvatars.load(.cover(session.userID), picture: profile?.coverPicture) - } .task(id: profile?.profilePicture?.thumbnailBlobID) { await sessionContainer.profileAvatars.load(userID: session.userID, picture: profile?.profilePicture) } @@ -65,28 +49,35 @@ struct EditProfileScreen: View { // MARK: - Header - + /// The You tab's cover and avatar at the same sizes, each a button to its own editor. private var header: some View { - ZStack(alignment: .bottomLeading) { + VStack(alignment: .leading, spacing: 0) { Button { router.push(.changeCoverPicture) } label: { - CoverBanner(image: coverImage, fill: CoverBanner.fill(for: profile)) + ProfileCoverBanner(userID: session.userID, coverPicture: profile?.coverPicture) } .buttonStyle(.plain) .accessibilityIdentifier("edit-profile-cover") - .padding(.bottom, Self.avatarSize / 2) Button { router.push(.changeProfilePicture) } label: { - CircleImage(image: avatarImage, size: Self.avatarSize, plusSize: 36) - .overlay(Circle().stroke(Color.backgroundMain, lineWidth: 4)) + ContactAvatarView( + id: session.userID.uuidString, + displayName: profile?.displayName ?? "", + imageData: sessionContainer.profileAvatars.data(for: session.userID), + blurhash: profile?.profilePicture?.thumbnailBlurhash, + size: ProfileHeaderView.avatarSize + ) + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } } .buttonStyle(.plain) .accessibilityIdentifier("edit-profile-photo") - .padding(.leading, 20) + .padding(.top, -ProfileHeaderView.avatarOverlap) + .padding(.leading, ProfileHeaderView.inset) + .padding(.bottom, 8) } - .padding(.bottom, 8) } // MARK: - Rows - @@ -166,12 +157,13 @@ private struct FieldRow: View { Text(title) .font(.appDisplayXS) .foregroundStyle(Color.textMain) - Spacer(minLength: 12) + // `Row` puts its own spacer before the chevron, so the value claims the width to sit against it. Text(value ?? placeholder) .font(.appTextMedium) .foregroundStyle(value == nil || valueIsPrompt ? Color.textSecondary : Color.textMain) .lineLimit(1) .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .trailing) } action: { action() } diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 06c400a5a..79f00a6fc 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -121,8 +121,7 @@ struct YouScreen: View { bannerControls: { EmptyView() }, rowActions: { ProfileEditCapsule { - // The Edit Profile slice repoints this to `.editProfile`. - router.push(.settings) + router.push(.editProfile) } .accessibilityIdentifier("you-edit-profile") diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift index 452582a80..2aa005f3b 100644 --- a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -17,6 +17,8 @@ struct ProfileCoverBanner: View { let userID: UserID let coverPicture: ProfilePicture? + /// A picked image not uploaded yet, drawn in place of the stored cover. + var preview: UIImage? = nil @ViewBuilder let controls: () -> Controls static var height: CGFloat { 214 } @@ -41,7 +43,9 @@ struct ProfileCoverBanner: View { @ViewBuilder private var cover: some View { - if let coverPicture { + 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) @@ -61,3 +65,11 @@ struct ProfileCoverBanner: View { .scaledToFill() } } + +extension ProfileCoverBanner where Controls == EmptyView { + + /// A cover with nothing laid over it. + init(userID: UserID, coverPicture: ProfilePicture?, preview: UIImage? = nil) { + self.init(userID: userID, coverPicture: coverPicture, preview: preview, controls: { EmptyView() }) + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index 1373cc514..690618fc7 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -27,9 +27,9 @@ struct ProfileHeaderView Date: Tue, 6 Oct 2026 11:25:50 -0400 Subject: [PATCH 03/15] feat(profile): lay out Edit Profile as Figma's cover card and field cards The cover sits in an inset card with a Change cover chip, the avatar overlaps its edge with a camera badge, and Name, Username, Bio and Minimum to Chat are label/value cards that open the existing editors. --- .../Main/Profile/EditProfileScreen.swift | 166 +++++++++++------- .../Screens/Profile/ProfileCoverBanner.swift | 8 +- 2 files changed, 110 insertions(+), 64 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index d00e1bb0d..3aa937a06 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -7,8 +7,8 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The signed-in user's profile fields in one list: cover, photo, then a row per field showing -/// its current value. Pushed from the You tab's Edit Profile button. +/// 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 @@ -16,7 +16,9 @@ struct EditProfileScreen: View { @State private var dialog: DialogItem? - private static let rowInsets = EdgeInsets(top: 25, leading: 20, bottom: 25, trailing: 20) + 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 } @@ -33,10 +35,13 @@ struct EditProfileScreen: View { Background(color: .backgroundMain) { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - header - fieldRows + cover + photo + fieldCards + .padding(.top, 20) } - .padding(.bottom, 24) + .padding(.horizontal, ProfileHeaderView.inset) + .padding(.vertical, 24) } } .navigationTitle("Edit Profile") @@ -47,70 +52,98 @@ struct EditProfileScreen: View { } } - // MARK: - Header - - - /// The You tab's cover and avatar at the same sizes, each a button to its own editor. - private var header: some View { - VStack(alignment: .leading, spacing: 0) { - Button { - router.push(.changeCoverPicture) - } label: { - ProfileCoverBanner(userID: session.userID, coverPicture: profile?.coverPicture) - } - .buttonStyle(.plain) - .accessibilityIdentifier("edit-profile-cover") + // 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)) + .overlay(alignment: .topTrailing) { + // Opaque: the standard chip's row fill is translucent and vanishes on a photo. + Text("Change cover") + .chip(.tinted(.textMain, on: .backgroundMain)) + .padding(Self.cardSpacing) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-profile-cover") + } - Button { - router.push(.changeProfilePicture) - } label: { + /// 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: ProfileHeaderView.avatarSize + 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) } - .buttonStyle(.plain) - .accessibilityIdentifier("edit-profile-photo") - .padding(.top, -ProfileHeaderView.avatarOverlap) - .padding(.leading, ProfileHeaderView.inset) - .padding(.bottom, 8) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-profile-photo") + .padding(.top, -Self.avatarSize / 2) + .padding(.leading, 16) } - // MARK: - Rows - + 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) + } - @ViewBuilder - private var fieldRows: some View { - FieldRow(title: "Display Name", value: nonEmpty(profile?.displayName), placeholder: "Add a name", insets: Self.rowInsets) { - router.push(.changeDisplayName) - } - .accessibilityIdentifier("edit-profile-name") + // MARK: - Fields - - usernameRow + 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") - FieldRow(title: "Bio", value: nonEmpty(profile?.bio), placeholder: "Add a bio", insets: Self.rowInsets) { - router.push(.editBio) - } - .accessibilityIdentifier("edit-profile-bio") + usernameCard + + FieldCard(title: "Bio", value: nonEmpty(profile?.bio), placeholder: "Add a bio", lineLimit: 3) { + router.push(.editBio) + } + .accessibilityIdentifier("edit-profile-bio") - FieldRow(title: "Minimum to Chat", value: minimumToChat, placeholder: "Not set", insets: Self.rowInsets) { - router.push(.setMinimumTip(isSetupStep: false)) + FieldCard(title: "Minimum to Chat", value: minimumToChat, placeholder: "Not set") { + router.push(.setMinimumTip(isSetupStep: false)) + } + .accessibilityIdentifier("edit-profile-minimum") } - .accessibilityIdentifier("edit-profile-minimum") } @ViewBuilder - private var usernameRow: some View { + private var usernameCard: some View { if needsUsernameClaim { - FieldRow(title: "Username", value: "Claim your username", placeholder: "", insets: Self.rowInsets, valueIsPrompt: true, action: beginUsernameClaim) + 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. - FieldRow(title: "Username", value: "@\(username.value)", placeholder: "", insets: Self.rowInsets) { + FieldCard(title: "Username", value: "@\(username.value)", placeholder: "") { router.push(.username(username)) } .accessibilityIdentifier("edit-profile-username") @@ -142,30 +175,41 @@ struct EditProfileScreen: View { } } -/// A tappable row with the field's title and its current value, one line, trailing. -private struct FieldRow: View { +/// 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 - let insets: EdgeInsets var valueIsPrompt = false + var lineLimit = 1 let action: VoidAction var body: some View { - Row(insets: insets, accessory: .chevron) { - Text(title) - .font(.appDisplayXS) - .foregroundStyle(Color.textMain) - // `Row` puts its own spacer before the chevron, so the value claims the width to sit against it. - Text(value ?? placeholder) - .font(.appTextMedium) - .foregroundStyle(value == nil || valueIsPrompt ? Color.textSecondary : Color.textMain) - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .trailing) - } action: { - action() + Button(action: action) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.appTextSmall) + .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) } } diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift index 2aa005f3b..2f235ee98 100644 --- a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -19,13 +19,15 @@ struct ProfileCoverBanner: View { 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: Self.height) + .frame(height: bannerHeight) .frame(maxWidth: .infinity) .overlay { cover } .clipped() @@ -69,7 +71,7 @@ struct ProfileCoverBanner: View { extension ProfileCoverBanner where Controls == EmptyView { /// A cover with nothing laid over it. - init(userID: UserID, coverPicture: ProfilePicture?, preview: UIImage? = nil) { - self.init(userID: userID, coverPicture: coverPicture, preview: preview, controls: { EmptyView() }) + init(userID: UserID, coverPicture: ProfilePicture?, preview: UIImage? = nil, bannerHeight: CGFloat = Self.height) { + self.init(userID: userID, coverPicture: coverPicture, preview: preview, bannerHeight: bannerHeight, controls: { EmptyView() }) } } From a8c30532f948f5e174b3a64f204cd0271ea05657 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:34:06 -0400 Subject: [PATCH 04/15] fix(profile): set Edit Profile card labels in appTextCaption A Medium-weight label reads as secondary next to the bold value. --- Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index 3aa937a06..adb9bc1d6 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -190,7 +190,7 @@ private struct FieldCard: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text(title) - .font(.appTextSmall) + .font(.appTextCaption) .foregroundStyle(Color.textSecondary) Text(value ?? placeholder) .font(.appTextMedium) From dbf755ddd6d9ff0ae4ff7d5b0b81cbddb5fd5068 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:38:08 -0400 Subject: [PATCH 05/15] feat(profile): make the Change cover chip Liquid Glass on iOS 26 iOS 18 keeps the opaque chip, since the row fill vanishes on a photo. --- .../Main/Profile/EditProfileScreen.swift | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index adb9bc1d6..64fb26f6e 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -61,9 +61,8 @@ struct EditProfileScreen: View { ProfileCoverBanner(userID: session.userID, coverPicture: profile?.coverPicture, bannerHeight: Self.coverHeight) .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) .overlay(alignment: .topTrailing) { - // Opaque: the standard chip's row fill is translucent and vanishes on a photo. Text("Change cover") - .chip(.tinted(.textMain, on: .backgroundMain)) + .modifier(CoverChip()) .padding(Self.cardSpacing) } } @@ -213,3 +212,20 @@ private struct FieldCard: View { .accessibilityElement(children: .combine) } } + +/// The "Change cover" label over the cover photo: a 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, in: .capsule) + } else { + content.chip(.tinted(.textMain, on: .backgroundMain)) + } + } +} From d4fd302577b20233bc39fa282f03f71c9e5e650a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:40:20 -0400 Subject: [PATCH 06/15] feat(profile): make Change cover its own darker, interactive glass button The glass takes a backgroundMain tint so the label holds on a bright cover, and the chip is a button beside the cover card rather than a label inside it, so the interactive glass responds to its own press. Both open the cover editor. --- .../Main/Profile/EditProfileScreen.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index 64fb26f6e..4c5ecbf82 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -60,14 +60,18 @@ struct EditProfileScreen: View { } label: { ProfileCoverBanner(userID: session.userID, coverPicture: profile?.coverPicture, bannerHeight: Self.coverHeight) .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) - .overlay(alignment: .topTrailing) { - Text("Change cover") - .modifier(CoverChip()) - .padding(Self.cardSpacing) - } } .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. @@ -213,7 +217,7 @@ private struct FieldCard: View { } } -/// The "Change cover" label over the cover photo: a Liquid Glass capsule on iOS 26, and on iOS 18 an +/// 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 { @@ -223,7 +227,7 @@ private struct CoverChip: ViewModifier { .foregroundStyle(Color.textMain) .padding(.horizontal, 10) .padding(.vertical, 6) - .glassEffect(.regular, in: .capsule) + .glassEffect(.regular.tint(Color.backgroundMain.opacity(0.6)).interactive(), in: .capsule) } else { content.chip(.tinted(.textMain, on: .backgroundMain)) } From cf0a6ac2d87e0e30cc7ce50c0d248ee1ffaa9356 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:48:39 -0400 Subject: [PATCH 07/15] feat(profile): move Download to the toolbar and Close to the bottom of the profile card Drop the link row and Share button; the profile's Share menu already offers both. --- .../Screens/Main/You/ProfileCardScreen.swift | 77 ++++-------- .../Main/You/TipCardActionButton.swift | 39 ------ .../Screens/Main/You/TipCardLinkRow.swift | 112 ------------------ .../Core/Screens/Main/You/YouScreen.swift | 2 +- FlipcashTests/TipCardLinkRowTests.swift | 38 ------ 5 files changed, 24 insertions(+), 244 deletions(-) delete mode 100644 Flipcash/Core/Screens/Main/You/TipCardActionButton.swift delete mode 100644 Flipcash/Core/Screens/Main/You/TipCardLinkRow.swift delete mode 100644 FlipcashTests/TipCardLinkRowTests.swift diff --git a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift index 06376bedc..43b06d938 100644 --- a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -8,15 +8,13 @@ import UIKit import FlipcashCore import FlipcashUI -/// The user's tip card full screen (Figma node 9277:121410), with the copyable public link and the -/// Share/Download pair under it. Presented over the You tab from the profile's Share menu. +/// The user's tip card full screen (Figma node 9277:121410), with Download in the toolbar and Close +/// pinned to the bottom. Presented over the You tab from the profile's Share menu. struct ProfileCardScreen: View { @Environment(SessionContainer.self) private var sessionContainer @Environment(\.dismiss) private var dismiss - /// Warmed by `YouScreen`, which outlives this screen, so reopening reuses the render. - let previewCache: TipCodePreviewCache @State private var isShowingDownloadOptions = false /// The format tapped in the download sheet, held until the sheet is gone so @@ -35,9 +33,9 @@ struct ProfileCardScreen: View { private static let boostedBrightness: CGFloat = 0.6 var body: some View { - Background(color: .backgroundMain) { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { + NavigationStack { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { if let name = displayName { TipcardView( size: cardSize, @@ -47,28 +45,27 @@ struct ProfileCardScreen: View { tintOpacity: 0.36, subtitle: username.map(\.handle) ) - .padding(.top, 24) + .padding(.horizontal, Self.horizontalInset) + .containerRelativeFrame(.vertical, alignment: .center) } - - TipCardLinkRow(url: url) - .padding(.top, 32) - - HStack(spacing: 10) { - TipCardActionButton(asset: .shareOS, title: "Share", action: shareTipCard) - .accessibilityIdentifier("you-share-button") - - TipCardActionButton(asset: .fileDownload, title: "Download") { - isShowingDownloadOptions = true - } - .accessibilityIdentifier("you-download-button") + } + } + .safeAreaInset(edge: .bottom) { + Button("Close") { dismiss() } + .buttonStyle(.subtle) + .accessibilityIdentifier("profile-card-close") + .padding(.horizontal, Self.horizontalInset) + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + isShowingDownloadOptions = true + } label: { + Image.asset(.fileDownload) } - .padding(.top, 11) - - closeButton - .padding(.top, 24) + .accessibilityLabel("Download") + .accessibilityIdentifier("you-download-button") } - .padding(.horizontal, Self.horizontalInset) - .containerRelativeFrame(.vertical, alignment: .center) } } .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { @@ -81,23 +78,6 @@ struct ProfileCardScreen: View { .onDisappear(perform: restoreBrightness) } - private var closeButton: some View { - Button { - dismiss() - } label: { - Text("Close") - .font(.appTextSmall) - .foregroundStyle(Color.textMain) - .opacity(0.5) - .padding(.vertical, 12) - .padding(.horizontal, 20) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") - .accessibilityIdentifier("profile-card-close") - } - // MARK: - Content - private var profile: Profile? { sessionContainer.session.profile } @@ -113,8 +93,6 @@ struct ProfileCardScreen: View { TipCode.Payload(userID: sessionContainer.session.userID).codeData() } - private var url: URL { .tipcard(for: sessionContainer.session.userID, username: username) } - private var cardSize: CGSize { let screenWidth = UIApplication.shared.firstWindowScene?.screen.bounds.width ?? Self.maxCardWidth let width = min(Self.maxCardWidth, screenWidth - Self.horizontalInset * 2) @@ -123,15 +101,6 @@ struct ProfileCardScreen: View { // MARK: - Actions - - private func shareTipCard() { - let item = TipCodeShareItem.profile( - url: url, - displayName: displayName, - preview: previewCache.preview(for: sessionContainer.session.userID) - ) - ShareSheet.present(activityItem: item) { _ in } - } - /// Exports the format the sheet picked and hands the file to the share /// sheet, which is where iOS puts "Save to Files" and every other /// destination. 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/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/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 79f00a6fc..10bda3f6e 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -86,7 +86,7 @@ struct YouScreen: View { } } .fullScreenCover(isPresented: Bindable(router).isShowingProfileCard) { - ProfileCardScreen(previewCache: previewCache) + ProfileCardScreen() } .task(id: profilePicture?.thumbnailBlobID) { // There is no card to share, and so no preview worth rendering, until diff --git a/FlipcashTests/TipCardLinkRowTests.swift b/FlipcashTests/TipCardLinkRowTests.swift deleted file mode 100644 index 8ddd26df9..000000000 --- a/FlipcashTests/TipCardLinkRowTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// TipCardLinkRowTests.swift -// FlipcashTests -// - -import Foundation -import Testing -import FlipcashCore -@testable import Flipcash - -@MainActor -@Suite("Tip card link row display text") -struct TipCardLinkRowTests { - - @Test("A uuid is still clipped to a stub") - func displayText_uuid_clipped() { - let url = URL(string: "https://flipcash.com/b0ced1d2-3f4a-4b5c-8d9e-0f1a2b3c4d5e")! - #expect(TipCardLinkRow.displayText(for: url) == "flipcash.com/b0ced…") - } - - @Test("The longest handle renders whole") - func displayText_maximumLengthHandle_unclipped() { - let url = URL(string: "https://flipcash.com/abcdefghijklmno")! - #expect(TipCardLinkRow.displayText(for: url) == "flipcash.com/abcdefghijklmno") - } - - @Test("A six-character handle renders whole") - func displayText_shortHandle_unclipped() { - let url = URL(string: "https://flipcash.com/taylor")! - #expect(TipCardLinkRow.displayText(for: url) == "flipcash.com/taylor") - } - - @Test("A non-handle segment at exactly the stub length is left alone") - func displayText_nonHandleAtStubLength_unclipped() { - let url = URL(string: "https://flipcash.com/a-bcd")! - #expect(TipCardLinkRow.displayText(for: url) == "flipcash.com/a-bcd") - } -} From 1e1ca4cb8c08b141af74e7d44cc3da7a7a63a0d1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:51:55 -0400 Subject: [PATCH 08/15] feat(profile): reveal the profile card with the scanned-card spring The card springs in from 0.55 scale with the bill pop's timing while the backdrop fades in, and Close plays it in reverse. The cover is presented and dismissed without its slide so the reveal is the only motion. --- .../Screens/Main/You/ProfileCardScreen.swift | 49 +++++++++++++++++-- .../Core/Screens/Main/You/YouScreen.swift | 9 +++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift index 43b06d938..6f8fd8314 100644 --- a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -24,6 +24,11 @@ struct ProfileCardScreen: View { /// The brightness to put back on close — set only when this screen raised it. @State private var previousBrightness: CGFloat? + /// The backdrop and chrome fade on this; the cover itself is presented without animation. + @State private var isRevealed = false + /// The card springs on this, separately, so it can bounce while the backdrop eases. + @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 @@ -32,9 +37,18 @@ struct ProfileCardScreen: View { 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) + private static let fade: Animation = .easeOut(duration: 0.25) + var body: some View { NavigationStack { - Background(color: .backgroundMain) { + ZStack { + Color.backgroundMain + .ignoresSafeArea() + .opacity(isRevealed ? 1 : 0) + ScrollView(showsIndicators: false) { if let name = displayName { TipcardView( @@ -45,14 +59,17 @@ struct ProfileCardScreen: View { tintOpacity: 0.36, subtitle: username.map(\.handle) ) + .scaleEffect(isCardShown ? 1 : Self.revealScale) + .opacity(isCardShown ? 1 : 0) .padding(.horizontal, Self.horizontalInset) .containerRelativeFrame(.vertical, alignment: .center) } } } .safeAreaInset(edge: .bottom) { - Button("Close") { dismiss() } + Button("Close", action: close) .buttonStyle(.subtle) + .opacity(isRevealed ? 1 : 0) .accessibilityIdentifier("profile-card-close") .padding(.horizontal, Self.horizontalInset) } @@ -65,16 +82,22 @@ struct ProfileCardScreen: View { } .accessibilityLabel("Download") .accessibilityIdentifier("you-download-button") + .opacity(isRevealed ? 1 : 0) } } + .containerBackground(.clear, for: .navigation) } + .presentationBackground(.clear) .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { TipCardDownloadSheet( onSelect: { pendingDownload = $0; isShowingDownloadOptions = false }, onCancel: { isShowingDownloadOptions = false } ) } - .onAppear(perform: boostBrightness) + .onAppear { + boostBrightness() + reveal() + } .onDisappear(perform: restoreBrightness) } @@ -123,6 +146,26 @@ struct ProfileCardScreen: View { } } + // MARK: - Reveal - + + private func reveal() { + Haptics.vibrate() + withAnimation(Self.fade) { isRevealed = true } + withAnimation(Self.revealSpring) { isCardShown = true } + } + + /// Plays the reveal backwards, then removes the cover without its slide-down. + private func close() { + withAnimation(Self.fade) { + isRevealed = false + isCardShown = false + } completion: { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { dismiss() } + } + } + // 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. diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index 10bda3f6e..d0403e4cb 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -300,11 +300,18 @@ struct YouScreen: View { ShareSheet.present(activityItem: item) { _ in } } + /// Presents the card without the cover's slide-up, so its own scan-style reveal is the only motion. + private func showProfileCard() { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { router.isShowingProfileCard = true } + } + private func handleShareChoice() { defer { shareChoice = nil } switch shareChoice { case .share: shareTipCard() - case .showCard: router.isShowingProfileCard = true + case .showCard: showProfileCard() case .copyLink: copyLink() case nil: break } From 93631cb331bce72a68b3f865e3ae22fb943b5364 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 11:54:56 -0400 Subject: [PATCH 09/15] fix(profile): fade the profile card out at full size on close --- Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift index 6f8fd8314..af700a847 100644 --- a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -26,7 +26,7 @@ struct ProfileCardScreen: View { /// The backdrop and chrome fade on this; the cover itself is presented without animation. @State private var isRevealed = false - /// The card springs on this, separately, so it can bounce while the backdrop eases. + /// 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). @@ -60,7 +60,7 @@ struct ProfileCardScreen: View { subtitle: username.map(\.handle) ) .scaleEffect(isCardShown ? 1 : Self.revealScale) - .opacity(isCardShown ? 1 : 0) + .opacity(isRevealed ? 1 : 0) .padding(.horizontal, Self.horizontalInset) .containerRelativeFrame(.vertical, alignment: .center) } @@ -154,11 +154,10 @@ struct ProfileCardScreen: View { withAnimation(Self.revealSpring) { isCardShown = true } } - /// Plays the reveal backwards, then removes the cover without its slide-down. + /// Fades the card and backdrop out at full size, then removes the cover without its slide-down. private func close() { withAnimation(Self.fade) { isRevealed = false - isCardShown = false } completion: { var transaction = Transaction() transaction.disablesAnimations = true From a32dd77065c37b6694ab57c330c482e8e44bef86 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:07:34 -0400 Subject: [PATCH 10/15] fix(profile): draw the profile card inside the You tab so the gear's glass swaps to Download in place The card was a full-screen cover with its own toolbar button laid over the You tab's gear. The two glass buttons sampled different backdrops, so the glass shifted and changed colour as the cover settled and cleared. The card is now an overlay in the You tab, the tab's one toolbar button swaps its glyph between Settings and Download, and the tab bar hides while it is up. --- .../Core/Screens/Main/Home/HomeTabView.swift | 5 +- .../Screens/Main/You/ProfileCardScreen.swift | 90 ++++++++----------- .../Core/Screens/Main/You/YouScreen.swift | 34 ++++--- .../HomeTabBarVisibilityTests.swift | 18 +++- 4 files changed, 79 insertions(+), 68 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 365378658..8e06856d0 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -74,6 +74,7 @@ struct HomeTabView: View { selection.hidesTabBar( isWalletCardExpanded: cardExpansion.isExpanded, isShowingBill: sessionContainer.session.isShowingBill, + isShowingProfileCard: router.isShowingProfileCard, hasPushedScreen: selection.pushStack.map { !router[$0].isEmpty } ?? false ) } @@ -354,12 +355,14 @@ extension HomeTab { func hidesTabBar( isWalletCardExpanded: Bool, isShowingBill: Bool, + isShowingProfileCard: Bool, hasPushedScreen: Bool ) -> Bool { if isShowingBill || hasPushedScreen { return true } switch self { case .wallet: return isWalletCardExpanded - case .tipCard, .scan, .chat: return false + case .tipCard: return isShowingProfileCard + case .scan, .chat: return false } } } diff --git a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift index af700a847..b7a112f83 100644 --- a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -8,14 +8,16 @@ import UIKit import FlipcashCore import FlipcashUI -/// The user's tip card full screen (Figma node 9277:121410), with Download in the toolbar and Close -/// pinned to the bottom. Presented over the You tab from the profile's Share menu. +/// 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(\.dismiss) private var dismiss + @Environment(AppRouter.self) private var router - @State private var isShowingDownloadOptions = false + /// 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. @@ -24,7 +26,7 @@ struct ProfileCardScreen: View { /// The brightness to put back on close — set only when this screen raised it. @State private var previousBrightness: CGFloat? - /// The backdrop and chrome fade on this; the cover itself is presented without animation. + /// 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 @@ -40,54 +42,40 @@ struct ProfileCardScreen: View { /// 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) - private static let fade: Animation = .easeOut(duration: 0.25) - var body: some View { - NavigationStack { - ZStack { - Color.backgroundMain - .ignoresSafeArea() - .opacity(isRevealed ? 1 : 0) + /// The open and close fade, shared with the toolbar glyph swap so the two move together. + static let fade: Animation = .easeOut(duration: 0.25) - 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) + 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) - .accessibilityIdentifier("profile-card-close") .padding(.horizontal, Self.horizontalInset) - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - isShowingDownloadOptions = true - } label: { - Image.asset(.fileDownload) - } - .accessibilityLabel("Download") - .accessibilityIdentifier("you-download-button") - .opacity(isRevealed ? 1 : 0) + .containerRelativeFrame(.vertical, alignment: .center) } } - .containerBackground(.clear, for: .navigation) } - .presentationBackground(.clear) + .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 }, @@ -154,15 +142,9 @@ struct ProfileCardScreen: View { withAnimation(Self.revealSpring) { isCardShown = true } } - /// Fades the card and backdrop out at full size, then removes the cover without its slide-down. + /// Fades the card out at full size; the overlay's removal transition carries the fade. private func close() { - withAnimation(Self.fade) { - isRevealed = false - } completion: { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { dismiss() } - } + withAnimation(Self.fade) { router.isShowingProfileCard = false } } // MARK: - Brightness - diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index d0403e4cb..bd7bff652 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -35,6 +35,7 @@ struct YouScreen: View { @State private var isShowingShare = false @State private var shareChoice: ProfileShareChoice? + @State private var isShowingCardDownload = false /// The gap the page keeps between its last row and the tab bar. private static let tabBarGap: CGFloat = 24 @@ -85,8 +86,11 @@ struct YouScreen: View { settingsGear } } - .fullScreenCover(isPresented: Bindable(router).isShowingProfileCard) { - ProfileCardScreen() + .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 @@ -152,14 +156,26 @@ struct YouScreen: View { .accessibilityIdentifier("you-share") } + /// 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 { - Button { - router.push(.settings) + let showsDownload = router.isShowingProfileCard + return Button { + if showsDownload { + isShowingCardDownload = true + } else { + router.push(.settings) + } } label: { Image(systemName: "gearshape") + .opacity(showsDownload ? 0 : 1) + .overlay { + Image.asset(.fileDownload) + .opacity(showsDownload ? 1 : 0) + } } - .accessibilityLabel("Settings") - .accessibilityIdentifier("you-settings") + .accessibilityLabel(showsDownload ? "Download" : "Settings") + .accessibilityIdentifier(showsDownload ? "you-download-button" : "you-settings") } // MARK: - No name - @@ -300,11 +316,9 @@ struct YouScreen: View { ShareSheet.present(activityItem: item) { _ in } } - /// Presents the card without the cover's slide-up, so its own scan-style reveal is the only motion. + /// Shows the card; it runs its own scan-style reveal, and the toolbar glyph fades to Download alongside. private func showProfileCard() { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { router.isShowingProfileCard = true } + withAnimation(ProfileCardScreen.fade) { router.isShowingProfileCard = true } } private func handleShareChoice() { diff --git a/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift b/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift index 476298cbe..a204a2620 100644 --- a/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift +++ b/FlipcashTests/Navigation/HomeTabBarVisibilityTests.swift @@ -15,6 +15,7 @@ struct HomeTabBarVisibilityTests { let hidden = tab.hidesTabBar( isWalletCardExpanded: true, isShowingBill: false, + isShowingProfileCard: false, hasPushedScreen: false ) #expect(hidden == (tab == .wallet)) @@ -22,12 +23,23 @@ struct HomeTabBarVisibilityTests { @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, hasPushedScreen: false)) - #expect(tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: false, hasPushedScreen: true)) + #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, + isShowingBill: false, + isShowingProfileCard: true, + hasPushedScreen: false + ) + #expect(hidden == (tab == .tipCard)) } @Test("nothing open shows the bar", arguments: HomeTab.allCases) func idle_shows(tab: HomeTab) { - #expect(!tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: false, hasPushedScreen: false)) + #expect(!tab.hidesTabBar(isWalletCardExpanded: false, isShowingBill: false, isShowingProfileCard: false, hasPushedScreen: false)) } } From 8f31920d6a5bc8b6b93547ffdc7ec2f9986c8068 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:33:17 -0400 Subject: [PATCH 11/15] test(profile): wait for the gear to return after closing the profile card --- FlipcashUITests/Smoke/ProfileCardSmokeTests.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/FlipcashUITests/Smoke/ProfileCardSmokeTests.swift b/FlipcashUITests/Smoke/ProfileCardSmokeTests.swift index 052fbf14f..ba9375611 100644 --- a/FlipcashUITests/Smoke/ProfileCardSmokeTests.swift +++ b/FlipcashUITests/Smoke/ProfileCardSmokeTests.swift @@ -5,7 +5,7 @@ import XCTest -/// Covers the profile card the You tab's Share menu presents full screen, and its Close button. +/// 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. @@ -28,9 +28,11 @@ final class ProfileCardSmokeTests: BaseUITestCase { 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-share"].waitForExistence(timeout: 10), - "Expected Close to return to the You tab" + app.buttons["you-settings"].waitForExistence(timeout: 10), + "Expected Close to return to the You tab, with the gear back in place of Download" ) } } From 7a851521731827de6dd261aa907548182ce6b885 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 12:36:01 -0400 Subject: [PATCH 12/15] test(profile): back out of Edit Profile in one step now that it opens from the You tab --- FlipcashUITests/Smoke/DisplayNameSmokeTests.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift index 6e8b622ad..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 Settings. +/// 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 @@ -101,10 +101,9 @@ final class DisplayNameSmokeTests: BaseUITestCase { ) // `ProfileNameScreen(completion: .back)` pops itself only once - // `SetDisplayName` returns, so landing back on Settings 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 Edit Profile - // rather than the You tab root. + // behind a dialog. XCTAssertTrue( settings.displayNameRow.waitForExistence(timeout: 60), "Expected the name editor to pop back to Edit Profile once the name saved. On screen: [\(visibleText())]" @@ -113,9 +112,9 @@ final class DisplayNameSmokeTests: BaseUITestCase { // 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) - waitAndTap(app.navigationBars.buttons.firstMatch) - assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of Settings") + assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of Edit Profile") XCTAssertTrue( app.buttons["you-share"].waitForExistence(timeout: 30), "Expected the profile still on the You tab after renaming" From 3327a056ce9efb863d7f49a04f17b7d6397acd52 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 13:05:48 -0400 Subject: [PATCH 13/15] fix(profile): focus editor fields once the push settles The keyboard and Save button rose while the screen was still sliding in, so Save animated in from off screen. Name, Username, Bio, username lookup and group name now focus after the push finishes. --- .../Screens/Main/Profile/EditBioScreen.swift | 2 +- .../Main/Profile/ProfileNameScreen.swift | 2 +- .../Main/Username/UsernameEntryScreen.swift | 6 ++---- .../Main/Username/UsernameLookupScreen.swift | 2 +- .../Edit Group/EditGroupNameScreen.swift | 2 +- .../FlipcashUI/Modifiers/FocusAfterPush.swift | 21 +++++++++++++++++++ 6 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 FlipcashUI/Sources/FlipcashUI/Modifiers/FocusAfterPush.swift diff --git a/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift index 71c5a4463..ab16141a1 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift @@ -77,7 +77,7 @@ struct EditBioScreen: View { .navigationTitle("Bio") .navigationBarTitleDisplayMode(.inline) .dialog(item: $dialog) - .onAppear { isFocused = true } + .focusAfterPush($isFocused) .onChange(of: model.failure == nil) { _, isNil in guard !isNil else { return } model.failure = nil 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/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..033a5cb91 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. 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/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 + } + } +} From 677b75ea4d4fb16c8a80e02eba18eae3d6fcddd6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 13:05:48 -0400 Subject: [PATCH 14/15] fix(settings): uppercase section headings and gate Account Info behind beta --- Flipcash/Core/Controllers/BetaFlags.swift | 3 +++ Flipcash/Core/Screens/Settings/SettingsScreen.swift | 8 +++++--- .../Core/Screens/Settings/SettingsSectionHeader.swift | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) 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/Screens/Settings/SettingsScreen.swift b/Flipcash/Core/Screens/Settings/SettingsScreen.swift index 9d22abcf7..3f3e78f26 100644 --- a/Flipcash/Core/Screens/Settings/SettingsScreen.swift +++ b/Flipcash/Core/Screens/Settings/SettingsScreen.swift @@ -108,10 +108,12 @@ struct SettingsScreen: View { VStack(alignment: .leading, spacing: 0) { SettingsSectionHeader("Account") - SettingsRow(systemImage: "person.crop.circle", title: "Account Info", insets: insets) { - router.push(.accountInfo) + if betaFlags.canViewAccountInfo { + SettingsRow(systemImage: "person.crop.circle", title: "Account Info", badge: .beta, insets: insets) { + router.push(.accountInfo) + } + .accessibilityIdentifier("settings-account-info-row") } - .accessibilityIdentifier("settings-account-info-row") SettingsRow(asset: .logout, title: "Log Out", insets: insets) { dialogItem = .alert( diff --git a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift index 61cc0d50a..3cd8ecce2 100644 --- a/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift +++ b/Flipcash/Core/Screens/Settings/SettingsSectionHeader.swift @@ -18,6 +18,7 @@ struct SettingsSectionHeader: View { var body: some View { HStack { Text(title) + .textCase(.uppercase) .font(.appTextHeading) .foregroundStyle(.textSecondary) Spacer() From b52d8a06db4479acb72316a7904f0ea09cbd68a7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 13:19:39 -0400 Subject: [PATCH 15/15] fix(profile): show the effective minimum to chat in Edit Profile Edit Profile read only the account's own fee and showed "Not set" when there was none, while the You tab showed the regional minimum others actually pay. Both now use StartChattingFee. --- Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index 4c5ecbf82..e6706b043 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -13,6 +13,7 @@ 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? @@ -153,8 +154,9 @@ struct EditProfileScreen: View { } } + /// What others pay to start a chat, matching the You tab's stats card. private var minimumToChat: String? { - profile?.minDmChatInitFee?.formatted() + StartChattingFee.amount(for: profile, session: session, ratesController: ratesController)?.formatted() } private func nonEmpty(_ string: String?) -> String? {