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/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index e64d65f6f..ca52e943d 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 the You tab's Edit Profile button. + 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/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/Profile/ChangeCoverPictureScreen.swift b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift new file mode 100644 index 000000000..17894f688 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/ChangeCoverPictureScreen.swift @@ -0,0 +1,170 @@ +// +// ChangeCoverPictureScreen.swift +// Flipcash +// + +import SwiftUI +import UniformTypeIdentifiers +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.profile-cover") + +/// Changing the cover banner on its own, pushed from Edit Profile. Owns its upload state so a +/// cover can never resume an avatar blob. +struct ChangeCoverPictureScreen: View { + + @Environment(Container.self) private var container + @Environment(SessionContainer.self) private var sessionContainer + @Environment(AppRouter.self) private var router + + @State private var state = ProfileCreationState() + @State private var isShowingPhotoPicker = false + @State private var isShowingFilePicker = false + @State private var dialog: DialogItem? + @State private var buttonState: ButtonState = .normal + + private var session: Session { sessionContainer.session } + private var profile: Profile? { session.profile } + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 0) { + Menu { + Button("Photo Library", systemImage: "photo.on.rectangle") { isShowingPhotoPicker = true } + Button("Choose File", systemImage: "folder") { isShowingFilePicker = true } + } label: { + ProfileCoverBanner( + userID: session.userID, + coverPicture: profile?.coverPicture, + preview: state.selectedImage + ) + .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius)) + .overlay { + if state.selectedImage == nil && profile?.coverPicture == nil { + Image(systemName: "plus") + .font(.appDisplayMedium) + .foregroundStyle(Color.textSecondary) + } + } + } + .menuIndicator(.hidden) + .disabled(state.isUploading) + .accessibilityIdentifier("cover-picker") + .padding(.top, 20) + + Spacer() + + Button(action: submit) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + .disabled(!state.canSubmitPhoto || !buttonState.isNormal) + .accessibilityIdentifier("cover-save") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + .navigationTitle("Cover") + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .fullScreenCover(isPresented: $isShowingPhotoPicker) { + ImagePickerWithEditor( + onImagePicked: state.select, + onDismiss: { isShowingPhotoPicker = false } + ) + .ignoresSafeArea() + } + .fileImporter( + isPresented: $isShowingFilePicker, + allowedContentTypes: [.image], + allowsMultipleSelection: false, + onCompletion: handleFileImport + ) + .task(id: state.uploadAttemptID) { + guard state.hasPendingUpload else { return } + await upload() + } + } + + private func submit() { + state.beginUpload() + } + + private func upload() async { + buttonState = .loading + do { + try await state.uploadPhoto( + with: SessionProfilePictureUploader( + session: session, + flipClient: container.flipClient, + slot: .cover + ) + ) + + buttonState = .success + try? await Task.delay(milliseconds: 500) + guard !Task.isCancelled else { return } + + state.releaseSelectedImage() + router.popTopmost() + + } catch let error as ErrorBlob { + buttonState = .normal + guard !Task.isCancelled else { return } + logger.info("Cover upload failed", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Cover upload failed", userFacing: true) + dialog = .profilePictureFailed(error) + + } catch let error as ImageEncoderError { + buttonState = .normal + logger.error("Failed to encode the cover", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to encode the cover") + dialog = .imageProcessingFailed + + } catch { + buttonState = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set cover picture", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set cover picture") + dialog = .error( + title: "Couldn't Upload Your Cover", + subtitle: "Try again" + ) + } + } + + private func handleFileImport(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + logger.info("Cover file import failed", metadata: ["error": "\(error)"]) + + case .success(let urls): + guard let url = urls.first else { return } + importImage(at: url) + } + } + + private func importImage(at url: URL) { + Task { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + let image = await Task.detached(priority: .userInitiated) { () -> UIImage? in + guard let data = try? Data(contentsOf: url) else { return nil } + return UIImage(data: data) + }.value + + guard let image else { + dialog = .error( + title: "Couldn't Open That File", + subtitle: "Try a different image" + ) + return + } + + state.select(image) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift b/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift new file mode 100644 index 000000000..9168d96d5 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditBioModel.swift @@ -0,0 +1,127 @@ +// +// EditBioModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.edit-bio") + +/// The bio editor's state and its one save. +/// +/// The calls are injected: `FlipClient` is a concrete class with a live gRPC channel, so a test +/// has nothing to fake. +@MainActor +@Observable +final class EditBioModel { + + /// What the server refused the bio for, shown under the field. Cleared by the next edit. + enum FieldError: Equatable { + case moderated + case invalid + + var message: String { + switch self { + case .moderated: "This bio isn't allowed. Try different wording" + case .invalid: "This bio isn't valid" + } + } + } + + /// Where the save is. `.saved` is held by the screen for its checkmark before it pops. + enum SaveState: Equatable { + case normal + case saving + case saved + } + + static let maxLength = 160 + + /// The bio as typed. Editing it clears ``fieldError``. + var text: String { + didSet { + guard text != oldValue else { return } + fieldError = nil + } + } + + private(set) var state: SaveState = .normal + private(set) var fieldError: FieldError? + + /// A failure with no field to blame, shown as a dialog. Set to nil once shown. + var failure: Error? + + @ObservationIgnored private let initialText: String + @ObservationIgnored private let validator = LengthValidator(maxLength: maxLength) + @ObservationIgnored private let saving: (String) async throws -> Void + @ObservationIgnored private let refresh: () async throws -> Void + + /// - Parameters: + /// - bio: the bio the profile carries now. + /// - saving: submits the trimmed bio. + /// - refresh: re-reads the profile so the rest of the app sees the new bio. + init( + bio: String, + saving: @escaping (String) async throws -> Void, + refresh: @escaping () async throws -> Void + ) { + self.text = bio + self.initialText = bio + self.saving = saving + self.refresh = refresh + } + + /// How many more characters fit, negative once over the limit. + var remaining: Int { + Self.maxLength - text.count + } + + /// Whether Save is enabled. Clearing the bio to empty is a change like any other. + var canSave: Bool { + state == .normal && submission != nil && submission != Self.trim(initialText) + } + + /// The bio as it would be submitted, or nil while it is over the limit. Empty is valid, unlike + /// for `LengthValidator`, which rejects blank input: clearing the bio is an edit. + private var submission: String? { + let trimmed = Self.trim(text) + return trimmed.isEmpty ? trimmed : validator.validate(trimmed) + } + + private static func trim(_ string: String) -> String { + string.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Submits the bio, then re-reads the profile. Failures land in ``fieldError`` or ``failure``. + func save() async { + guard canSave, let submission else { return } + + state = .saving + fieldError = nil + + do { + try await saving(submission) + try await refresh() + state = .saved + + } catch ErrorProfile.moderated(let category) { + state = .normal + logger.info("Bio moderation denied", metadata: ["category": "\(category)"]) + fieldError = .moderated + + } catch ErrorProfile.invalidBio { + state = .normal + logger.info("Bio rejected as invalid") + fieldError = .invalid + + } catch { + state = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set bio", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set bio") + failure = error + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift new file mode 100644 index 000000000..ab16141a1 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditBioScreen.swift @@ -0,0 +1,104 @@ +// +// EditBioScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Editing the bio on its own, pushed from Edit Profile. +struct EditBioScreen: View { + + @Environment(AppRouter.self) private var router + + @State private var model: EditBioModel + @State private var dialog: DialogItem? + @State private var saveTask: Task? + + @FocusState private var isFocused: Bool + + /// Takes the model rather than building it, so the field opens on the bio it is about to + /// replace instead of animating it in over the push. + init(model: EditBioModel) { + _model = State(initialValue: model) + } + + private var buttonState: ButtonState { + switch model.state { + case .normal: .normal + case .saving: .loading + case .saved: .success + } + } + + var body: some View { + @Bindable var model = model + + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 0) { + TextField("Add a bio", text: $model.text, axis: .vertical) + .font(.appDisplayXS) + .foregroundStyle(Color.textMain) + .lineLimit(3...8) + .focused($isFocused) + .disabled(model.state != .normal) + .accessibilityIdentifier("edit-bio-field") + .padding(.top, 20) + + Text("\(model.remaining)") + .font(.appTextSmall) + .foregroundStyle(model.remaining < 0 ? Color.textError : Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.top, 8) + .accessibilityIdentifier("edit-bio-counter") + + if let fieldError = model.fieldError { + Text(fieldError.message) + .font(.appTextSmall) + .foregroundStyle(Color.textError) + .padding(.top, 8) + .accessibilityIdentifier("edit-bio-error") + } + + Spacer() + + Button(action: save) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + .disabled(!model.canSave) + .accessibilityIdentifier("edit-bio-save") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .navigationTitle("Bio") + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .focusAfterPush($isFocused) + .onChange(of: model.failure == nil) { _, isNil in + guard !isNil else { return } + model.failure = nil + dialog = .error(title: "Couldn't Save Your Bio", subtitle: "Try again") + } + // Leaving the screen abandons the submission: its only continuation is a pop. + .onDisappear { saveTask?.cancel() } + } + + private func save() { + guard saveTask == nil else { return } + isFocused = false + saveTask = Task { + await model.save() + if model.state == .saved { + // Same beat the other editors hold their checkmark for. + try? await Task.delay(milliseconds: 500) + guard !Task.isCancelled else { return } + router.popTopmost() + } + saveTask = nil + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift new file mode 100644 index 000000000..e6706b043 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -0,0 +1,237 @@ +// +// EditProfileScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The signed-in user's profile fields as cards under an inset cover and photo, each opening its own +/// editor. Pushed from the You tab's Edit Profile button. +struct EditProfileScreen: View { + + @Environment(AppRouter.self) private var router + @Environment(SessionContainer.self) private var sessionContainer + @Environment(RatesController.self) private var ratesController + + @State private var dialog: DialogItem? + + private static let coverHeight: CGFloat = 126 + private static let avatarSize: CGFloat = 68 + private static let cardSpacing: CGFloat = 12 + + private var session: Session { sessionContainer.session } + private var profile: Profile? { session.profile } + + /// Whether the handle still needs claiming: none yet, or one the server assigned. + private var needsUsernameClaim: Bool { + usernameNeedsClaim( + username: profile?.username, + isAutoAssigned: profile?.isUsernameAutoAssigned == true + ) + } + + var body: some View { + Background(color: .backgroundMain) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + cover + photo + fieldCards + .padding(.top, 20) + } + .padding(.horizontal, ProfileHeaderView.inset) + .padding(.vertical, 24) + } + } + .navigationTitle("Edit Profile") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .task(id: profile?.profilePicture?.thumbnailBlobID) { + await sessionContainer.profileAvatars.load(userID: session.userID, picture: profile?.profilePicture) + } + } + + // MARK: - Cover & photo - + + private var cover: some View { + Button { + router.push(.changeCoverPicture) + } label: { + ProfileCoverBanner(userID: session.userID, coverPicture: profile?.coverPicture, bannerHeight: Self.coverHeight) + .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-profile-cover") + .overlay(alignment: .topTrailing) { + Button("Change cover") { + router.push(.changeCoverPicture) + } + .buttonStyle(.plain) + .modifier(CoverChip()) + .padding(Self.cardSpacing) + .accessibilityIdentifier("edit-profile-change-cover") + } + } + + /// The avatar overlapping the cover's bottom edge, with a camera badge and a caption beside it. + private var photo: some View { + Button { + router.push(.changeProfilePicture) + } label: { + HStack(alignment: .bottom, spacing: Self.cardSpacing) { + ContactAvatarView( + id: session.userID.uuidString, + displayName: profile?.displayName ?? "", + imageData: sessionContainer.profileAvatars.data(for: session.userID), + blurhash: profile?.profilePicture?.thumbnailBlurhash, + size: Self.avatarSize + ) + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } + .overlay(alignment: .bottomTrailing) { cameraBadge } + + Text("Change photo") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .padding(.bottom, 6) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-profile-photo") + .padding(.top, -Self.avatarSize / 2) + .padding(.leading, 16) + } + + private var cameraBadge: some View { + Image.asset(.camera) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .frame(width: 28, height: 28) + // Opaque so the avatar doesn't show through; the same grey as the header's buttons. + .background(Circle().fill(Color.action.opacity(0.1))) + .background(Circle().fill(Color.backgroundMain)) + .offset(x: 6, y: 2) + } + + // MARK: - Fields - + + private var fieldCards: some View { + VStack(spacing: Self.cardSpacing) { + FieldCard(title: "Name", value: nonEmpty(profile?.displayName), placeholder: "Add a name") { + router.push(.changeDisplayName) + } + .accessibilityIdentifier("edit-profile-name") + + usernameCard + + FieldCard(title: "Bio", value: nonEmpty(profile?.bio), placeholder: "Add a bio", lineLimit: 3) { + router.push(.editBio) + } + .accessibilityIdentifier("edit-profile-bio") + + FieldCard(title: "Minimum to Chat", value: minimumToChat, placeholder: "Not set") { + router.push(.setMinimumTip(isSetupStep: false)) + } + .accessibilityIdentifier("edit-profile-minimum") + } + } + + @ViewBuilder + private var usernameCard: some View { + if needsUsernameClaim { + FieldCard(title: "Username", value: "Claim your username", placeholder: "", valueIsPrompt: true, action: beginUsernameClaim) + .accessibilityIdentifier("edit-profile-username") + } else if let username = profile?.username { + // No balance gate on a held handle: the gate exists to stop squatting at claim time, + // and a user who already holds one has cleared it. + FieldCard(title: "Username", value: "@\(username.value)", placeholder: "") { + router.push(.username(username)) + } + .accessibilityIdentifier("edit-profile-username") + } + } + + /// What others pay to start a chat, matching the You tab's stats card. + private var minimumToChat: String? { + StartChattingFee.amount(for: profile, session: session, ratesController: ratesController)?.formatted() + } + + private func nonEmpty(_ string: String?) -> String? { + guard let string, !string.isEmpty else { return nil } + return string + } + + // MARK: - Username claim - + + /// The same entry the You tab's progress card takes: the claim screen once the balance clears + /// the server's minimum, the balance dialog otherwise. + private func beginUsernameClaim() { + switch usernameGate(session: session, minimum: session.userFlags?.usernameMinBalance) { + case .proceed: + router.push(.username(profile?.username)) + case .addMoney(let minimum, _, _): + dialog = .usernameMinimumBalance(minimum: minimum) { + router.presentAddMoney(.general, source: .usernameShortfall) + } + } + } +} + +/// A tappable card with the field's title over its current value, styled like the profile's stats card. +private struct FieldCard: View { + + let title: String + let value: String? + let placeholder: String + var valueIsPrompt = false + var lineLimit = 1 + let action: VoidAction + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + Text(value ?? placeholder) + .font(.appTextMedium) + .foregroundStyle(value == nil || valueIsPrompt ? Color.textSecondary : Color.textMain) + .lineLimit(lineLimit) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + } +} + +/// The "Change cover" button over the cover photo: an interactive Liquid Glass capsule on iOS 26, and on iOS 18 an +/// opaque chip, since the standard chip's row fill is translucent and vanishes on a photo. +private struct CoverChip: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + .font(.appTextSmall) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .glassEffect(.regular.tint(Color.backgroundMain.opacity(0.6)).interactive(), in: .capsule) + } else { + content.chip(.tinted(.textMain, on: .backgroundMain)) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift b/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift index 5ffacefaf..def99b9b8 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfileCreationState.swift @@ -115,7 +115,7 @@ final class ProfileCreationState { throw ErrorBlob.rejected(reason) } - try await uploader.setProfilePicture(blobID: blobID) + try await uploader.attach(blobID: blobID) try await uploader.refreshProfile() } diff --git a/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift b/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift index 7bd08ba00..65363c06e 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfileNameScreen.swift @@ -89,7 +89,7 @@ struct ProfileNameScreen: View { } .navigationBarTitleDisplayMode(.inline) .dialog(item: $dialog) - .onAppear { isNameFocused = true } + .focusAfterPush($isNameFocused) // Leaving the screen abandons the submission: its only continuation is a // push onto a stack this screen no longer sits on. .onDisappear { submitTask?.cancel() } diff --git a/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift b/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift index 71b33b779..2b2df4ce1 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfilePhotoScreen.swift @@ -153,7 +153,8 @@ struct ProfilePhotoScreen: View { try await state.uploadPhoto( with: SessionProfilePictureUploader( session: sessionContainer.session, - flipClient: container.flipClient + flipClient: container.flipClient, + slot: .avatar ) ) diff --git a/Flipcash/Core/Screens/Main/Profile/ProfilePictureUploading.swift b/Flipcash/Core/Screens/Main/Profile/ProfilePictureUploading.swift index de7186fd8..c67143ef5 100644 --- a/Flipcash/Core/Screens/Main/Profile/ProfilePictureUploading.swift +++ b/Flipcash/Core/Screens/Main/Profile/ProfilePictureUploading.swift @@ -18,18 +18,27 @@ protocol ProfilePictureUploading { /// is refused and `ErrorBlob.timedOut` when it is still processing. func awaitBlobFinalization(blobID: BlobID) async throws - /// Attaches the finalized blob to the caller's profile. - func setProfilePicture(blobID: BlobID) async throws + /// Attaches the finalized blob to the profile slot this uploader targets. + func attach(blobID: BlobID) async throws /// Re-reads the profile so the rest of the app sees the new picture. func refreshProfile() async throws } +/// Which picture on the profile an upload replaces. +enum ProfilePictureSlot: Sendable { + /// The round profile picture. + case avatar + /// The banner behind it. + case cover +} + /// Uploads on behalf of the signed-in owner. struct SessionProfilePictureUploader: ProfilePictureUploading { let session: Session let flipClient: FlipClient + let slot: ProfilePictureSlot func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair) @@ -39,8 +48,29 @@ struct SessionProfilePictureUploader: ProfilePictureUploading { try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) } - func setProfilePicture(blobID: BlobID) async throws { - try await flipClient.setProfilePicture(blobID: blobID, owner: session.ownerKeyPair) + func attach(blobID: BlobID) async throws { + let owner = session.ownerKeyPair + try await Self.attach( + blobID, + to: slot, + setAvatar: { try await flipClient.setProfilePicture(blobID: $0, owner: owner) }, + // The `refreshProfile` that follows re-reads the cover, so the echo is dropped. + setCover: { _ = try await flipClient.setCoverPicture(blobID: $0, owner: owner) } + ) + } + + /// Routes `blobID` to the call for `slot`. Closures stand in for `FlipClient`, a concrete + /// class with a live gRPC channel, so a test can observe the routing. + static func attach( + _ blobID: BlobID, + to slot: ProfilePictureSlot, + setAvatar: (BlobID) async throws -> Void, + setCover: (BlobID) async throws -> Void + ) async throws { + switch slot { + case .avatar: try await setAvatar(blobID) + case .cover: try await setCover(blobID) + } } func refreshProfile() async throws { diff --git a/Flipcash/Core/Screens/Main/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/Main/You/ProfileCardScreen.swift b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift index 06376bedc..b7a112f83 100644 --- a/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift +++ b/Flipcash/Core/Screens/Main/You/ProfileCardScreen.swift @@ -8,16 +8,16 @@ 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 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 - /// Warmed by `YouScreen`, which outlives this screen, so reopening reuses the render. - let previewCache: TipCodePreviewCache - @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. @@ -26,6 +26,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 Close fade in on this; removal fades the whole overlay instead. + @State private var isRevealed = false + /// The card's scale springs on this, separately, so it can bounce while its opacity eases with the backdrop. + @State private var isCardShown = false + /// The card's width, from the full-screen frame (302 of the 402pt frame). private static let maxCardWidth: CGFloat = 302 private static let horizontalInset: CGFloat = 20 @@ -34,68 +39,54 @@ 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) + + /// The open and close fade, shared with the toolbar glyph swap so the two move together. + static let fade: Animation = .easeOut(duration: 0.25) + var body: some View { - Background(color: .backgroundMain) { + ZStack { + Color.backgroundMain + .ignoresSafeArea() + .opacity(isRevealed ? 1 : 0) + ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - if let name = displayName { - TipcardView( - size: cardSize, - name: name, - avatar: nil, - codeData: codeData, - tintOpacity: 0.36, - subtitle: username.map(\.handle) - ) - .padding(.top, 24) - } - - TipCardLinkRow(url: url) - .padding(.top, 32) - - HStack(spacing: 10) { - TipCardActionButton(asset: .shareOS, title: "Share", action: shareTipCard) - .accessibilityIdentifier("you-share-button") - - TipCardActionButton(asset: .fileDownload, title: "Download") { - isShowingDownloadOptions = true - } - .accessibilityIdentifier("you-download-button") - } - .padding(.top, 11) - - closeButton - .padding(.top, 24) + 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) } - .padding(.horizontal, Self.horizontalInset) - .containerRelativeFrame(.vertical, alignment: .center) } } + .safeAreaInset(edge: .bottom) { + Button("Close", action: close) + .buttonStyle(.subtle) + .opacity(isRevealed ? 1 : 0) + .accessibilityIdentifier("profile-card-close") + .padding(.horizontal, Self.horizontalInset) + } .sheet(isPresented: $isShowingDownloadOptions, onDismiss: exportPendingDownload) { TipCardDownloadSheet( onSelect: { pendingDownload = $0; isShowingDownloadOptions = false }, onCancel: { isShowingDownloadOptions = false } ) } - .onAppear(perform: boostBrightness) - .onDisappear(perform: restoreBrightness) - } - - private var closeButton: some View { - Button { - dismiss() - } label: { - Text("Close") - .font(.appTextSmall) - .foregroundStyle(Color.textMain) - .opacity(0.5) - .padding(.vertical, 12) - .padding(.horizontal, 20) - .contentShape(Rectangle()) + .onAppear { + boostBrightness() + reveal() } - .buttonStyle(.plain) - .accessibilityLabel("Close") - .accessibilityIdentifier("profile-card-close") + .onDisappear(perform: restoreBrightness) } // MARK: - Content - @@ -113,8 +104,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 +112,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. @@ -154,6 +134,19 @@ struct ProfileCardScreen: View { } } + // MARK: - Reveal - + + private func reveal() { + Haptics.vibrate() + withAnimation(Self.fade) { isRevealed = true } + withAnimation(Self.revealSpring) { isCardShown = true } + } + + /// Fades the card out at full size; the overlay's removal transition carries the fade. + private func close() { + withAnimation(Self.fade) { router.isShowingProfileCard = false } + } + // MARK: - Brightness - /// The card is a code someone is about to scan, so a dim screen is raised for as long as it is up. 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 06c400a5a..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(previewCache: previewCache) + .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 @@ -121,8 +125,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") @@ -153,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 - @@ -301,11 +316,16 @@ struct YouScreen: View { ShareSheet.present(activityItem: item) { _ in } } + /// Shows the card; it runs its own scan-style reveal, and the toolbar glyph fades to Download alongside. + private func showProfileCard() { + withAnimation(ProfileCardScreen.fade) { router.isShowingProfileCard = true } + } + 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 } diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupNameScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupNameScreen.swift index 7e5cfa0a5..0da271350 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupNameScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupNameScreen.swift @@ -94,7 +94,7 @@ struct EditGroupNameScreen: View { // a display name; a bar title on top of it would ask twice. .navigationBarTitleDisplayMode(.inline) .dialog(item: $dialog) - .onAppear { isNameFocused = true } + .focusAfterPush($isNameFocused) // Leaving the screen abandons the submission: its only continuation is a pop off a stack // this screen no longer sits on. .onDisappear { submitTask?.cancel() } diff --git a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift index 452582a80..2f235ee98 100644 --- a/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift +++ b/Flipcash/Core/Screens/Profile/ProfileCoverBanner.swift @@ -17,13 +17,17 @@ 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 + /// 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() @@ -41,7 +45,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 +67,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, bannerHeight: CGFloat = Self.height) { + self.init(userID: userID, coverPicture: coverPicture, preview: preview, bannerHeight: bannerHeight, controls: { EmptyView() }) + } +} diff --git a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift index 1373cc514..690618fc7 100644 --- a/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift +++ b/Flipcash/Core/Screens/Profile/ProfileHeaderView.swift @@ -27,9 +27,9 @@ struct ProfileHeaderView 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/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)) } } 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/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") - } -} diff --git a/FlipcashUI/Sources/FlipcashUI/Modifiers/FocusAfterPush.swift b/FlipcashUI/Sources/FlipcashUI/Modifiers/FocusAfterPush.swift new file mode 100644 index 000000000..13ff936dd --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Modifiers/FocusAfterPush.swift @@ -0,0 +1,21 @@ +// +// FocusAfterPush.swift +// FlipcashUI +// + +import SwiftUI + +extension View { + + /// Focuses `focus` once the push that brought this screen in has finished, so the keyboard and + /// anything pinned above it rise on a screen that is already in place. + public func focusAfterPush(_ focus: FocusState.Binding) -> some View { + task { + // A navigation push runs about 0.5s; focusing during it slides the keyboard's + // accessories in from off screen alongside the incoming view. + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + focus.wrappedValue = true + } + } +} diff --git a/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift b/FlipcashUITests/Smoke/DisplayNameSmokeTests.swift index 6627dee1b..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 @@ -52,8 +52,8 @@ final class DisplayNameSmokeTests: BaseUITestCase { "Expected the Edit Profile button under the stats" ) - // MARK: Change the name from Settings. - waitAndTap(settings.gear) + // MARK: Change the name from Edit Profile. + waitAndTap(settings.editProfileButton) waitAndTap(settings.displayNameRow) let save = app.buttons["profile-name-next-button"] @@ -101,20 +101,20 @@ 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 Settings - // rather than the You tab root. + // behind a dialog. 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. + // Edit Profile is one push from the root; a second tap would land on the gear. waitAndTap(app.navigationBars.buttons.firstMatch) - assertMainScreenReached(timeout: 30, "Expected the You tab root after backing out of 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" 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" ) } } diff --git a/FlipcashUITests/Support/Screens/SettingsScreen.swift b/FlipcashUITests/Support/Screens/SettingsScreen.swift index 512cc8786..7ecf70475 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 You tab button that opens Edit Profile. + var editProfileButton: XCUIElement { app.buttons["you-edit-profile"] } + + /// Edit Profile's row that opens the display-name editor. + var displayNameRow: XCUIElement { app.buttons["edit-profile-name"] } /// The Privacy row that opens the Blocked list. var blockedRow: XCUIElement { app.buttons["Blocked"] }