From 2ef7117ab241da734cb7e04f5bde7035567360b9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:37:17 -0400 Subject: [PATCH 1/8] feat(profile): favorite public groups on profiles Edit Profile gets a "Favorite Public Groups" card that opens a picker of the user's public groups, saved with SetFeaturedGroups. The You tab and other users' profiles list those groups under the stats card and open the chat on tap; the section hides when there are none. The picker's candidates are the currently featured groups plus joined public groups, so a group the user has since left can still be removed. Selection keeps the server's order, appends new picks, and caps at 10, which the contract enforces as a validation error rather than a result code. A denied save (a group went private) gets its own dialog. Own-profile state lives in a FeaturedGroups unit on SessionContainer, shared by the You tab, the Edit Profile card and the picker. Featured groups stay out of ConversationStore: they are list-shaped and may belong to chats the viewer is not in. --- .claude/plans/2026-10-06-featured-groups.md | 26 +++ .../Navigation/AppRouter+Destination.swift | 7 +- .../AppRouter+DestinationView.swift | 17 ++ .../Profile/EditFeaturedGroupsModel.swift | 167 ++++++++++++++ .../Profile/EditFeaturedGroupsScreen.swift | 128 ++++++++++ .../Main/Profile/EditProfileScreen.swift | 12 + .../Core/Screens/Main/You/YouScreen.swift | 9 + .../Core/Screens/Profile/FeaturedGroups.swift | 50 ++++ .../Profile/FeaturedGroupsSection.swift | 99 ++++++++ .../Screens/Profile/UserProfileScreen.swift | 21 +- .../Core/Session/SessionAuthenticator.swift | 7 + .../EditFeaturedGroupsModelTests.swift | 218 ++++++++++++++++++ .../Navigation/SettingsRoutingTests.swift | 1 + 13 files changed, 759 insertions(+), 3 deletions(-) create mode 100644 .claude/plans/2026-10-06-featured-groups.md create mode 100644 Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift create mode 100644 Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift create mode 100644 Flipcash/Core/Screens/Profile/FeaturedGroups.swift create mode 100644 Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift create mode 100644 FlipcashTests/EditFeaturedGroupsModelTests.swift diff --git a/.claude/plans/2026-10-06-featured-groups.md b/.claude/plans/2026-10-06-featured-groups.md new file mode 100644 index 000000000..18ac8b14f --- /dev/null +++ b/.claude/plans/2026-10-06-featured-groups.md @@ -0,0 +1,26 @@ +# Favorite public groups on profiles (flipcash2 0.18.0) + +Contract + wrappers already on main (257a089d, 721fb442): `FlipClient.getFeaturedGroups(owner:username:)`, +`setFeaturedGroups(owner:conversationIDs:)`. This plan is the app layer and UI only. + +Branch `feat/profile-featured-groups`, cut from `feat/profile-refresh-copy` (#990) with `origin/main` merged in +(the stack predates the 0.18.0 wrappers). + +## Proto facts that shape the design +- Ordered list, max 10 (PGV `max_items`, so exceeding it is a transport invalid-argument — cap client-side). +- Written whole; empty clears. Only public groups (`.denied` otherwise, names no group). +- Featuring is independent of membership: leaving a group does not unfeature it. +- `GetFeaturedGroups` returns list-view `Metadata` (no members, viewer state, last message, cover). + +## Shape +- `FeaturedGroups` (`@MainActor @Observable`, `let` on `SessionContainer`): the signed-in user's list, + shared by the You tab section, the Edit Profile row ("N selected") and the picker. Closures injected + (fetch/save) like `EditBioModel`. Never seated into `ConversationStore`. +- `UserProfileViewModel.featuredGroups`: fetched after the profile (needs `username`). +- `FeaturedGroupsSection` view: "Favorite Public Groups" header + rows (group avatar, title, + description ?? people count); tap → `router.push(.tipConversation(id))`. Hidden when empty. +- Edit Profile: `FieldCard` "Favorite Public Groups" → new `.editFeaturedGroups` destination. +- Picker `EditFeaturedGroupsScreen` + `EditFeaturedGroupsModel`: candidates = current featured (server + order) ∪ joined public groups from `loadGroupFeed()`'s result (`isPrivate` isn't persisted, so the + store's copy can't be trusted offline; fallback = store joined groups with `!isPrivate && !useE2Ee`). + Search by title, ordered selection, cap 10, bottom filled Save like `EditBioScreen`. diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 13a85d080..fc67e67c0 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -64,6 +64,8 @@ extension AppRouter { case editProfile /// The bio on its own, pushed from Edit Profile. case editBio + /// The public groups shown on the signed-in user's profile, chosen from Edit Profile. + case editFeaturedGroups /// The cover banner on its own, pushed from Edit Profile. case changeCoverPicture /// The display name on its own, edited from Settings. The full @@ -141,7 +143,7 @@ extension AppRouter { .buyCurrency, .convertCurrency, .withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress: return .balance - case .settings, .accountInfo, .editProfile, .editBio, .changeCoverPicture, + case .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture, .changeDisplayName, .changeProfilePicture, .username, .setMinimumTip, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, @@ -180,6 +182,7 @@ extension AppRouter { case .accountInfo: "accountInfo" case .editProfile: "editProfile" case .editBio: "editBio" + case .editFeaturedGroups: "editFeaturedGroups" case .changeCoverPicture: "changeCoverPicture" case .changeDisplayName: "changeDisplayName" case .changeProfilePicture: "changeProfilePicture" @@ -242,7 +245,7 @@ extension AppRouter { case .activity, .discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard, .usdcDepositEducation, .usdcDepositAddress, - .settings, .accountInfo, .editProfile, .editBio, .changeCoverPicture, + .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture, .changeDisplayName, .changeProfilePicture, .settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection, .settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw, diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index f35f2359c..b7328c9b7 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -100,6 +100,23 @@ struct DestinationView: View { refresh: { try await session.updateProfile() } )) + case .editFeaturedGroups: + let session = sessionContainer.session + let flipClient = container.flipClient + let featuredGroups = sessionContainer.featuredGroups + let conversationController = sessionContainer.conversationController + EditFeaturedGroupsScreen(model: EditFeaturedGroupsModel( + featured: featuredGroups.groups, + loadingFeatured: { + // The list is read by handle; with none there is nothing to read back. + guard let username = session.profile?.username else { return [] } + return await featuredGroups.load(username: username) ? featuredGroups.groups : nil + }, + joinedGroups: { await conversationController.loadGroupFeed() }, + saving: { try await flipClient.setFeaturedGroups(owner: session.ownerKeyPair, conversationIDs: $0) }, + saved: { featuredGroups.replace(with: $0) } + )) + case .changeCoverPicture: ChangeCoverPictureScreen() diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift new file mode 100644 index 000000000..bddbd464f --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsModel.swift @@ -0,0 +1,167 @@ +// +// EditFeaturedGroupsModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.edit-featured-groups") + +/// The favorite-groups picker's choices, ordered selection and its one save. +/// +/// The calls are injected: `FlipClient` is a concrete class with a live gRPC channel, so a test +/// has nothing to fake. +@MainActor +@Observable +final class EditFeaturedGroupsModel { + + /// Where the save is. `.saved` is held by the screen for its checkmark before it pops. + enum SaveState: Equatable { + case normal + case saving + case saved + } + + /// Where the candidate list is. + enum LoadState: Equatable { + case loading + case loaded + /// The featured list could not be read, so a save could drop groups the screen never saw. + case failed + } + + /// Why a save failed, for the dialog. + enum Failure: Equatable { + /// The server refused a group as private. It does not say which. + case privateGroup + case other + } + + /// The groups on offer: the ones featured now, in their order, then the public groups the user + /// has joined. + private(set) var candidates: [Conversation] + + /// The chosen groups, in the order they will show on the profile. + private(set) var selection: [ConversationID] + + private(set) var state: SaveState = .normal + private(set) var loadState: LoadState = .loading + + /// Filters ``visibleCandidates`` by title. + var query = "" + + /// A failed save, shown as a dialog. Set to nil once shown. + var failure: Failure? + + @ObservationIgnored private var initialSelection: [ConversationID] + @ObservationIgnored private let loadingFeatured: () async -> [Conversation]? + @ObservationIgnored private let joinedGroups: () async -> [Conversation] + @ObservationIgnored private let saving: ([ConversationID]) async throws -> [Conversation] + @ObservationIgnored private let saved: ([Conversation]) -> Void + + /// - Parameters: + /// - featured: the groups the profile featured when last read, shown while they are re-read. + /// - loadingFeatured: re-reads the featured groups, in order; nil when that fails. + /// - joinedGroups: the groups the user is in, as the server says now. + /// - saving: submits the ordered selection and returns what is now featured. + /// - saved: hands that list to the rest of the app. + init( + featured: [Conversation], + loadingFeatured: @escaping () async -> [Conversation]?, + joinedGroups: @escaping () async -> [Conversation], + saving: @escaping ([ConversationID]) async throws -> [Conversation], + saved: @escaping ([Conversation]) -> Void + ) { + self.candidates = featured + self.selection = featured.map(\.id) + self.initialSelection = featured.map(\.id) + self.loadingFeatured = loadingFeatured + self.joinedGroups = joinedGroups + self.saving = saving + self.saved = saved + } + + /// The candidates whose title matches ``query``. + var visibleCandidates: [Conversation] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return candidates } + return candidates.filter { $0.groupLinkTitle.localizedCaseInsensitiveContains(query) } + } + + /// Whether Save is enabled: only once the featured list has been read, so a save never drops + /// groups the screen did not know about. Clearing every group is a change like any other. + var canSave: Bool { + state == .normal && loadState == .loaded && selection != initialSelection + } + + /// Whether the group is chosen. + func isSelected(_ id: ConversationID) -> Bool { + selection.contains(id) + } + + /// Whether tapping the group would change anything: always for a chosen one, and for the rest + /// only while there is room under ``FeaturedGroups/limit``. + func canToggle(_ id: ConversationID) -> Bool { + state == .normal && (isSelected(id) || selection.count < FeaturedGroups.limit) + } + + /// Chooses the group at the end of the order, or drops it. + func toggle(_ id: ConversationID) { + guard canToggle(id) else { return } + if let index = selection.firstIndex(of: id) { + selection.remove(at: index) + } else { + selection.append(id) + } + } + + /// Re-reads the featured groups, then offers the user's joined public groups after them. + /// + /// Featured groups stay on offer even when the user has left them: leaving does not unfeature + /// a group, so dropping it from the list would leave no way to remove it. + func loadCandidates() async { + guard let featured = await loadingFeatured() else { + loadState = .failed + return + } + let featuredIDs = featured.map(\.id) + // A choice made while the list was loading stands; an untouched one follows the server. + if selection == initialSelection { + selection = featuredIDs + } + initialSelection = featuredIDs + + let joined = await joinedGroups() + let known = Set(featuredIDs) + candidates = featured + joined.filter { $0.type == .group && !$0.isPrivate && !known.contains($0.id) } + loadState = .loaded + } + + /// Submits the selection. Failures land in ``failure``. + func save() async { + guard canSave else { return } + + state = .saving + + do { + let result = try await saving(selection) + saved(result) + state = .saved + + } catch let error as ErrorSetFeaturedGroups where error == .denied { + state = .normal + logger.info("Featured groups denied") + ErrorReporting.captureError(error, reason: "Featured groups denied") + failure = .privateGroup + + } catch { + state = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set featured groups", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set featured groups") + failure = .other + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift new file mode 100644 index 000000000..21155a8f2 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -0,0 +1,128 @@ +// +// EditFeaturedGroupsScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Choosing the public groups the profile features, pushed from Edit Profile. +struct EditFeaturedGroupsScreen: View { + + @Environment(AppRouter.self) private var router + + @State private var model: EditFeaturedGroupsModel + @State private var dialog: DialogItem? + @State private var saveTask: Task? + + /// Takes the model rather than building it, so the list opens on the groups featured now. + init(model: EditFeaturedGroupsModel) { + _model = State(initialValue: model) + } + + private var buttonState: ButtonState { + switch model.state { + case .normal: .normal + case .saving: .loading + case .saved: .success + } + } + + var body: some View { + @Bindable var model = model + + Background(color: .backgroundMain) { + List { + Section(header: ListHeader("\(model.selection.count) of \(FeaturedGroups.limit) selected")) { + ForEach(model.visibleCandidates) { group in + row(group) + } + } + .listRowSeparatorTint(Color.rowSeparator) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .overlay { emptyState } + } + .navigationTitle("Favorite Public Groups") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $model.query, prompt: "Search Groups") + .scrollEdgeBar(.bottom) { + Button(action: save) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + .disabled(!model.canSave) + .accessibilityIdentifier("edit-featured-groups-save") + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 8) + } + .dialog(item: $dialog) + .task { await model.loadCandidates() } + .onChange(of: model.failure) { _, failure in + guard let failure else { return } + model.failure = nil + switch failure { + case .privateGroup: + dialog = .error(title: "Only Public Groups Can Be Featured", subtitle: "One of these groups is private. Remove it and try again") + case .other: + dialog = .error(title: "Couldn't Save Your Groups", subtitle: "Try again") + } + } + // Leaving the screen abandons the submission: its only continuation is a pop. + .onDisappear { saveTask?.cancel() } + } + + private func row(_ group: Conversation) -> some View { + Button { + model.toggle(group.id) + } label: { + FeaturedGroupRow(group: group) { + CheckView(active: model.isSelected(group.id)) + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(model.canToggle(group.id) ? 1 : 0.4) + .disabled(!model.canToggle(group.id)) + .listRowBackground(Color.backgroundMain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(group.featuredRowAccessibilityLabel)) + .accessibilityAddTraits(model.isSelected(group.id) ? [.isButton, .isSelected] : .isButton) + .accessibilityIdentifier("edit-featured-groups-row") + } + + @ViewBuilder + private var emptyState: some View { + if !model.query.isEmpty, model.visibleCandidates.isEmpty { + SearchResultsUnavailableView(searchText: model.query) + } else if model.loadState == .failed { + Text("Couldn't load your groups") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + } else if model.loadState == .loaded, model.candidates.isEmpty { + Text("Join a public group to feature it on your profile") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } + + private func save() { + guard saveTask == nil else { return } + saveTask = Task { + await model.save() + if model.state == .saved { + // Same beat the other editors hold their checkmark for. + try? await Task.delay(milliseconds: 500) + guard !Task.isCancelled else { return } + router.popTopmost() + } + saveTask = nil + } + } +} diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index e6706b043..49a134465 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -136,6 +136,11 @@ struct EditProfileScreen: View { router.push(.setMinimumTip(isSetupStep: false)) } .accessibilityIdentifier("edit-profile-minimum") + + FieldCard(title: "Favorite Public Groups", value: featuredGroupsSummary, placeholder: "Add groups") { + router.push(.editFeaturedGroups) + } + .accessibilityIdentifier("edit-profile-featured-groups") } } @@ -154,6 +159,13 @@ struct EditProfileScreen: View { } } + /// How many groups the profile features, or nil for none. + private var featuredGroupsSummary: String? { + let count = sessionContainer.featuredGroups.groups.count + guard count > 0 else { return nil } + return count == 1 ? "1 group" : "\(count) groups" + } + /// What others pay to start a chat, matching the You tab's stats card. private var minimumToChat: String? { StartChattingFee.amount(for: profile, session: session, ratesController: ratesController)?.formatted() diff --git a/Flipcash/Core/Screens/Main/You/YouScreen.swift b/Flipcash/Core/Screens/Main/You/YouScreen.swift index bd7bff652..bb0651491 100644 --- a/Flipcash/Core/Screens/Main/You/YouScreen.swift +++ b/Flipcash/Core/Screens/Main/You/YouScreen.swift @@ -71,6 +71,11 @@ struct YouScreen: View { joinedAt: profile?.joinedAt ) .padding(.top, 19) + + FeaturedGroupsSection(groups: sessionContainer.featuredGroups.groups) { + router.push(.tipConversation($0)) + } + .padding(.top, 20) } } .padding(.bottom, bottomContentInset) @@ -101,6 +106,10 @@ struct YouScreen: View { } await sessionContainer.profileAvatars.load(userID: sessionContainer.session.userID, picture: profilePicture) } + .task(id: username) { + guard let username else { return } + await sessionContainer.featuredGroups.load(username: username) + } // A cash link raises the bill without touching the router, and it would draw under the card. .onChange(of: sessionContainer.session.isShowingBill) { _, isShowing in if isShowing { router.isShowingProfileCard = false } diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroups.swift b/Flipcash/Core/Screens/Profile/FeaturedGroups.swift new file mode 100644 index 000000000..503ab84d7 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/FeaturedGroups.swift @@ -0,0 +1,50 @@ +// +// FeaturedGroups.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore + +private let logger = Logger(label: "flipcash.featured-groups") + +/// The public groups the signed-in user features on their profile, in the order they chose. +/// +/// One list read by the You tab, Edit Profile and the picker, so a save shows everywhere at once. +/// Kept apart from `ConversationStore`: the server answers list-view metadata with no members or +/// viewer state, and featuring a group does not require being in it. +@MainActor +@Observable +final class FeaturedGroups { + + /// The most featured groups a profile can carry; the server rejects more. + static let limit = 10 + + private(set) var groups: [Conversation] = [] + + @ObservationIgnored private let fetching: (Username) async throws -> [Conversation] + + /// - Parameter fetching: reads the groups a username features. + init(fetching: @escaping (Username) async throws -> [Conversation]) { + self.fetching = fetching + } + + /// Re-reads the list for `username`, returning whether it did. A failure keeps the list already shown. + @discardableResult + func load(username: Username) async -> Bool { + do { + groups = try await fetching(username) + return true + } catch { + guard !Task.isCancelled else { return false } + logger.error("Failed to load featured groups", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to load featured groups") + return false + } + } + + /// Takes the list a save returned, which is the server's word on what is now featured. + func replace(with groups: [Conversation]) { + self.groups = groups + } +} diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift new file mode 100644 index 000000000..89f2f4546 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -0,0 +1,99 @@ +// +// FeaturedGroupsSection.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The "Favorite Public Groups" card on a profile, one row per group in the owner's order. Draws +/// nothing when the list is empty. +struct FeaturedGroupsSection: View { + + let groups: [Conversation] + let onTap: (ConversationID) -> Void + + var body: some View { + if !groups.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Color.rowSeparator + .frame(height: 1) + + Text("Favorite Public Groups") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .padding(.top, 24) + .padding(.bottom, 12) + .accessibilityAddTraits(.isHeader) + + VStack(spacing: 0) { + ForEach(groups) { group in + Button { onTap(group.id) } label: { + FeaturedGroupRow(group: group) { EmptyView() } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(group.featuredRowAccessibilityLabel)) + .accessibilityAddTraits(.isButton) + .accessibilityIdentifier("profile-featured-group") + + if group.id != groups.last?.id { + Color.rowSeparator + .frame(height: 1) + .padding(.leading, 72) + } + } + } + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } + .padding(.horizontal, ProfileHeaderView.inset) + .accessibilityIdentifier("profile-featured-groups") + } + } +} + +/// A public group as profiles and the picker show it: its picture, title, and its description or +/// head count. Never names members, which a viewer outside the group must not see. +struct FeaturedGroupRow: View { + + let group: Conversation + @ViewBuilder let trailing: Trailing + + @Environment(SessionContainer.self) private var sessionContainer + + private var subtitle: String { group.featuredRowSubtitle } + + var body: some View { + RecipientRowBody( + avatarID: group.id.description, + title: group.groupLinkTitle, + subtitle: AttributedString(subtitle), + imageData: sessionContainer.profileAvatars.data(for: .chat(group.id)), + blurhash: group.picture?.thumbnailBlurhash + ) { + trailing + } + .task(id: group.picture?.thumbnailBlobID) { + await sessionContainer.profileAvatars.load(.chat(group.id), picture: group.picture) + } + } +} + +extension Conversation { + /// The secondary line of a featured-group row: the group's description, or its member count when it has none. + var featuredRowSubtitle: String { + if let description = description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty { + return description + } + return rosterSummary.peopleCount + } + + /// The single VoiceOver label for a featured-group row: title, then subtitle. + var featuredRowAccessibilityLabel: String { + "\(groupLinkTitle), \(featuredRowSubtitle)" + } +} diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index 6d06b8a82..f5a08bdde 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -141,6 +141,11 @@ private struct UserProfileContent: View { ProfileStatsCard(minimumToChat: fee, joinedAt: model.joinedAt) .padding(.top, 20) + + FeaturedGroupsSection(groups: model.featuredGroups) { + router.push(.tipConversation($0)) + } + .padding(.top, 20) } .padding(.bottom, 24) } @@ -202,7 +207,10 @@ private struct UserProfileContent: View { .onAppear { if position == nil { position = router.positionOfTopmost() } } - .task { await model.loadProfile() } + .task { + await model.loadProfile() + await model.loadFeaturedGroups() + } } // MARK: - Status badges - @@ -421,6 +429,8 @@ final class UserProfileViewModel { private(set) var customization: TipCardCustomization? private(set) var joinedAt: Date? private(set) var minDmChatInitFee: FiatAmount? + /// The public groups this person features, in their order. + private(set) var featuredGroups: [Conversation] = [] /// What to call this person: their name when they have one, their handle /// when they don't. A handle is public and stable, so it beats the generic @@ -485,6 +495,15 @@ final class UserProfileViewModel { await profileAvatars.load(userID: userID, picture: profile.profilePicture) } + /// Reads the groups this person features, once their handle is known. Best effort: a profile + /// without the section is still the profile. + func loadFeaturedGroups() async { + guard let username, + let groups = try? await flipClient.getFeaturedGroups(owner: owner, username: username) + else { return } + featuredGroups = groups + } + private func apply(_ profile: Profile) { if let name = profile.displayName, !name.isEmpty { self.name = name } if let username = profile.username { self.username = username } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 1d39d6a57..9a80b3a61 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -578,6 +578,9 @@ final class SessionContainer { /// The emoji the signed-in user reacts with most, for the reaction strip and picker. let recentReactions: RecentReactionsStore + /// The public groups the signed-in user features on their profile. + let featuredGroups: FeaturedGroups + init( session: Session, database: Database, @@ -714,6 +717,10 @@ final class SessionContainer { } let recentReactions = RecentReactionsStore(owner: owner.publicKey) self.recentReactions = recentReactions + self.featuredGroups = FeaturedGroups(fetching: { [weak flipClient] username in + guard let flipClient else { return [] } + return try await flipClient.getFeaturedGroups(owner: owner, username: username) + }) conversationController.reactions.recents = recentReactions conversationController.start() self.conversationController = conversationController diff --git a/FlipcashTests/EditFeaturedGroupsModelTests.swift b/FlipcashTests/EditFeaturedGroupsModelTests.swift new file mode 100644 index 000000000..e3d12532d --- /dev/null +++ b/FlipcashTests/EditFeaturedGroupsModelTests.swift @@ -0,0 +1,218 @@ +// +// EditFeaturedGroupsModelTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("EditFeaturedGroupsModel") +struct EditFeaturedGroupsModelTests { + + private struct Boom: Error {} + + @MainActor + private final class Calls { + var submitted: [[ConversationID]] = [] + var handedBack: [[Conversation]] = [] + } + + private func group(_ byte: UInt8, title: String? = nil, isPrivate: Bool = false, type: ConversationType = .group) -> Conversation { + Conversation( + id: ConversationID(data: Data(repeating: byte, count: 32)), + members: [], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: type, + title: title, + isPrivate: isPrivate + ) + } + + private func makeModel( + cached: [Conversation] = [], + featured: [Conversation]? = [], + joined: [Conversation] = [], + calls: Calls = Calls(), + saving: (([ConversationID]) async throws -> [Conversation])? = nil + ) -> EditFeaturedGroupsModel { + EditFeaturedGroupsModel( + featured: cached, + loadingFeatured: { featured }, + joinedGroups: { joined }, + saving: { ids in + calls.submitted.append(ids) + return try await saving?(ids) ?? [] + }, + saved: { calls.handedBack.append($0) } + ) + } + + @Test("Candidates are the featured groups in their order, then joined public groups") + func candidatesOrder() async { + let a = group(1), b = group(2), c = group(3), secret = group(4, isPrivate: true), dm = group(5, type: .tipDm) + let model = makeModel(featured: [b, a], joined: [a, c, secret, dm]) + + await model.loadCandidates() + + #expect(model.candidates.map(\.id) == [b.id, a.id, c.id]) + #expect(model.selection == [b.id, a.id]) + #expect(model.loadState == .loaded) + } + + @Test("A featured group the user has left stays on offer so it can be removed") + func leftGroupStays() async { + let left = group(1) + let model = makeModel(featured: [left], joined: []) + + await model.loadCandidates() + + #expect(model.candidates.map(\.id) == [left.id]) + #expect(model.isSelected(left.id)) + } + + @Test("Toggling appends at the end of the order and removes in place") + func toggleOrder() async { + let a = group(1), b = group(2), c = group(3) + let model = makeModel(featured: [a], joined: [b, c]) + await model.loadCandidates() + + model.toggle(c.id) + model.toggle(b.id) + #expect(model.selection == [a.id, c.id, b.id]) + + model.toggle(a.id) + #expect(model.selection == [c.id, b.id]) + } + + @Test("No more than ten can be chosen, and a chosen one can still be dropped") + func capsAtLimit() async { + let groups = (1...11).map { group(UInt8($0)) } + let model = makeModel(featured: [], joined: groups) + await model.loadCandidates() + + groups.prefix(FeaturedGroups.limit).forEach { model.toggle($0.id) } + #expect(model.selection.count == FeaturedGroups.limit) + #expect(!model.canToggle(groups[10].id)) + + model.toggle(groups[10].id) + #expect(model.selection.count == FeaturedGroups.limit) + + #expect(model.canToggle(groups[0].id)) + model.toggle(groups[0].id) + #expect(model.selection.count == FeaturedGroups.limit - 1) + } + + @Test("Save stays shut until the featured list is read, and while unchanged") + func canSave() async { + let a = group(1), b = group(2) + let model = makeModel(cached: [a], featured: [a], joined: [b]) + + model.toggle(a.id) + #expect(!model.canSave) + + await model.loadCandidates() + // A choice made while loading stands. + #expect(model.selection.isEmpty) + #expect(model.canSave) + + model.toggle(a.id) + #expect(!model.canSave) + } + + @Test("A failed read of the featured list keeps Save shut") + func failedLoad() async { + let a = group(1) + let model = makeModel(cached: [], featured: nil, joined: [a]) + + await model.loadCandidates() + model.toggle(a.id) + + #expect(model.loadState == .failed) + #expect(!model.canSave) + } + + @Test("An untouched selection follows the re-read list") + func untouchedSelectionFollowsServer() async { + let stale = group(1), fresh = group(2) + let model = makeModel(cached: [stale], featured: [fresh]) + + await model.loadCandidates() + + #expect(model.selection == [fresh.id]) + #expect(!model.canSave) + } + + @Test("Saving submits the ordered selection and hands back the server's list") + func saveSubmits() async { + let a = group(1), b = group(2) + let calls = Calls() + let model = makeModel(featured: [a], joined: [b], calls: calls) { _ in [b, a] } + await model.loadCandidates() + model.toggle(a.id) + model.toggle(b.id) + model.toggle(a.id) + + await model.save() + + #expect(calls.submitted == [[b.id, a.id]]) + #expect(calls.handedBack.map { $0.map(\.id) } == [[b.id, a.id]]) + #expect(model.state == .saved) + } + + @Test("Clearing every group saves an empty list") + func saveEmpty() async { + let a = group(1) + let calls = Calls() + let model = makeModel(featured: [a], calls: calls) + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(calls.submitted == [[]]) + } + + @Test("A denied save names the private-group failure and leaves the selection") + func deniedSave() async { + let a = group(1) + let model = makeModel(featured: [], joined: [a]) { _ in throw ErrorSetFeaturedGroups.denied } + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(model.failure == .privateGroup) + #expect(model.state == .normal) + #expect(model.selection == [a.id]) + } + + @Test("Any other failure is generic") + func otherFailure() async { + let a = group(1) + let model = makeModel(featured: [], joined: [a]) { _ in throw Boom() } + await model.loadCandidates() + model.toggle(a.id) + + await model.save() + + #expect(model.failure == .other) + #expect(model.state == .normal) + } + + @Test("Search filters by title, ignoring case") + func search() async { + let cats = group(1, title: "Cat People"), dogs = group(2, title: "Dog people") + let model = makeModel(featured: [], joined: [cats, dogs]) + await model.loadCandidates() + + model.query = "cat" + #expect(model.visibleCandidates.map(\.id) == [cats.id]) + + model.query = " " + #expect(model.visibleCandidates.count == 2) + } +} diff --git a/FlipcashTests/Navigation/SettingsRoutingTests.swift b/FlipcashTests/Navigation/SettingsRoutingTests.swift index e16994558..7aaaab98e 100644 --- a/FlipcashTests/Navigation/SettingsRoutingTests.swift +++ b/FlipcashTests/Navigation/SettingsRoutingTests.swift @@ -15,6 +15,7 @@ struct SettingsRoutingTests { AppRouter.Destination.accountInfo, AppRouter.Destination.editProfile, AppRouter.Destination.editBio, + AppRouter.Destination.editFeaturedGroups, AppRouter.Destination.changeCoverPicture, ]) func owningStack(destination: AppRouter.Destination) { From 31000fdd480ca264342188dad3777a8893b2c6f4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:47:45 -0400 Subject: [PATCH 2/8] fix(profile): draw favorite public groups as Figma does The section now sits under a divider with a heading-weight title, and its rows drop the card and separators for a 48pt rounded-square picture beside the title and description (node 10961:111). ContactAvatarView takes an optional corner radius for that; every existing caller stays a circle. Another person's featured groups now report a failed load instead of dropping it silently, matching the own-profile path. --- .../Profile/EditFeaturedGroupsScreen.swift | 1 - .../Profile/FeaturedGroupsSection.swift | 39 +++++++++++-------- .../Screens/Profile/UserProfileScreen.swift | 15 ++++--- .../Views/Contacts/ContactAvatarView.swift | 15 ++++++- 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift index 21155a8f2..9d0c4d745 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -82,7 +82,6 @@ struct EditFeaturedGroupsScreen: View { FeaturedGroupRow(group: group) { CheckView(active: model.isSelected(group.id)) } - .padding(.vertical, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift index 89f2f4546..7dad1bd19 100644 --- a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -27,12 +27,10 @@ struct FeaturedGroupsSection: View { .padding(.bottom, 12) .accessibilityAddTraits(.isHeader) - VStack(spacing: 0) { + VStack(spacing: 2) { ForEach(groups) { group in Button { onTap(group.id) } label: { FeaturedGroupRow(group: group) { EmptyView() } - .padding(.horizontal, 16) - .padding(.vertical, 12) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -40,15 +38,8 @@ struct FeaturedGroupsSection: View { .accessibilityLabel(Text(group.featuredRowAccessibilityLabel)) .accessibilityAddTraits(.isButton) .accessibilityIdentifier("profile-featured-group") - - if group.id != groups.last?.id { - Color.rowSeparator - .frame(height: 1) - .padding(.leading, 72) - } } } - .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) } .padding(.horizontal, ProfileHeaderView.inset) .accessibilityIdentifier("profile-featured-groups") @@ -68,15 +59,29 @@ struct FeaturedGroupRow: View { private var subtitle: String { group.featuredRowSubtitle } var body: some View { - RecipientRowBody( - avatarID: group.id.description, - title: group.groupLinkTitle, - subtitle: AttributedString(subtitle), - imageData: sessionContainer.profileAvatars.data(for: .chat(group.id)), - blurhash: group.picture?.thumbnailBlurhash - ) { + HStack(spacing: 14) { + ContactAvatarView( + id: group.id.description, + displayName: group.groupLinkTitle, + imageData: sessionContainer.profileAvatars.data(for: .chat(group.id)), + blurhash: group.picture?.thumbnailBlurhash, + size: 48, + cornerRadius: 15 + ) + VStack(alignment: .leading, spacing: 3) { + Text(group.groupLinkTitle) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .lineLimit(1) + Text(subtitle) + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 12) trailing } + .frame(minHeight: 66) .task(id: group.picture?.thumbnailBlobID) { await sessionContainer.profileAvatars.load(.chat(group.id), picture: group.picture) } diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index f5a08bdde..6beaae56a 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -495,13 +495,16 @@ final class UserProfileViewModel { await profileAvatars.load(userID: userID, picture: profile.profilePicture) } - /// Reads the groups this person features, once their handle is known. Best effort: a profile - /// without the section is still the profile. + /// Reads the groups this person features, once their handle is known. A failure leaves the + /// section hidden; the rest of the profile stands. func loadFeaturedGroups() async { - guard let username, - let groups = try? await flipClient.getFeaturedGroups(owner: owner, username: username) - else { return } - featuredGroups = groups + guard let username else { return } + do { + featuredGroups = try await flipClient.getFeaturedGroups(owner: owner, username: username) + } catch { + guard !Task.isCancelled else { return } + ErrorReporting.captureError(error, reason: "Failed to load featured groups") + } } private func apply(_ profile: Profile) { diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift index d91675814..4ab272c6d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Contacts/ContactAvatarView.swift @@ -21,8 +21,11 @@ public struct ContactAvatarView: View { /// The monogram's color. Full-strength by default; a surface whose design mutes the initials /// passes a dimmer one. public let initialsColor: Color + /// Rounds the avatar into a square with this corner radius; `nil` keeps it a circle. + public let cornerRadius: CGFloat? - public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44, initialsColor: Color = .textMain) { + public init(id: String, displayName: String, imageData: Data? = nil, blurhash: String? = nil, size: CGFloat = 44, initialsColor: Color = .textMain, cornerRadius: CGFloat? = nil) { + self.cornerRadius = cornerRadius self.id = id self.displayName = displayName self.imageData = imageData @@ -31,6 +34,14 @@ public struct ContactAvatarView: View { self.initialsColor = initialsColor } + private var clipShape: AnyShape { + if let cornerRadius { + AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } else { + AnyShape(Circle()) + } + } + public var body: some View { Group { if let imageData, @@ -61,7 +72,7 @@ public struct ContactAvatarView: View { } } .frame(width: size, height: size) - .clipShape(Circle()) + .clipShape(clipShape) .accessibilityElement(children: .ignore) .accessibilityLabel(Text(displayName.isEmpty ? "Contact" : displayName)) .accessibilityAddTraits(.isImage) From d4edd74b16275e5c0fd47229b91f61a26c0fd2eb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 14:57:23 -0400 Subject: [PATCH 3/8] fix(profile): match the favorite groups picker to Figma Titles the screen "Favorite Groups", drops the row separators, and adds the footer line from node 10961:188. The search prompt now reads "Search your public groups". --- .../Main/Profile/EditFeaturedGroupsScreen.swift | 16 ++++++++++++---- .../Screens/Profile/FeaturedGroupsSection.swift | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift index 9d0c4d745..6b7efaba8 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -34,20 +34,28 @@ struct EditFeaturedGroupsScreen: View { Background(color: .backgroundMain) { List { - Section(header: ListHeader("\(model.selection.count) of \(FeaturedGroups.limit) selected")) { + Section { ForEach(model.visibleCandidates) { group in row(group) } + } header: { + ListHeader("\(model.selection.count) of \(FeaturedGroups.limit) selected") + } footer: { + if !model.candidates.isEmpty { + Text("These public groups will appear on your public profile") + .font(.appTextCaption) + .foregroundStyle(Color.textSecondary) + } } - .listRowSeparatorTint(Color.rowSeparator) + .listRowSeparator(.hidden) } .listStyle(.grouped) .scrollContentBackground(.hidden) .overlay { emptyState } } - .navigationTitle("Favorite Public Groups") + .navigationTitle("Favorite Groups") .navigationBarTitleDisplayMode(.inline) - .searchable(text: $model.query, prompt: "Search Groups") + .searchable(text: $model.query, prompt: "Search your public groups") .scrollEdgeBar(.bottom) { Button(action: save) { ButtonStateLabel("Save", state: buttonState) diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift index 7dad1bd19..9944f6ad2 100644 --- a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -7,7 +7,7 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// The "Favorite Public Groups" card on a profile, one row per group in the owner's order. Draws +/// The "Favorite Public Groups" section on a profile, one row per group in the owner's order. Draws /// nothing when the list is empty. struct FeaturedGroupsSection: View { From 810acaabdc03b3b0d3a99a1c5748b440d3cc9b48 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 15:45:58 -0400 Subject: [PATCH 4/8] fix(profile): tighten the favorite groups list and its tokens Group descriptions use appTextSmall, the subtitle token RecipientRow uses, instead of the smaller appTextCaption, and wrap to two lines. Rows drop the 66pt minimum height that stacked on the list's own insets. The picker shows a spinner until the group feed is read, so the list appears whole instead of growing from the featured groups. Save sits below the list, as in the other Edit Profile editors, rather than over it. --- .../Profile/EditFeaturedGroupsScreen.swift | 75 ++++++++++++------- .../Profile/FeaturedGroupsSection.swift | 5 +- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift index 6b7efaba8..2c8e18a49 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -33,40 +33,23 @@ struct EditFeaturedGroupsScreen: View { @Bindable var model = model Background(color: .backgroundMain) { - List { - Section { - ForEach(model.visibleCandidates) { group in - row(group) - } - } header: { - ListHeader("\(model.selection.count) of \(FeaturedGroups.limit) selected") - } footer: { - if !model.candidates.isEmpty { - Text("These public groups will appear on your public profile") - .font(.appTextCaption) - .foregroundStyle(Color.textSecondary) - } + VStack(spacing: 0) { + content + + Button(action: save) { + ButtonStateLabel("Save", state: buttonState) } - .listRowSeparator(.hidden) + .buttonStyle(.filled) + .disabled(!model.canSave) + .accessibilityIdentifier("edit-featured-groups-save") + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) } - .listStyle(.grouped) - .scrollContentBackground(.hidden) - .overlay { emptyState } } .navigationTitle("Favorite Groups") .navigationBarTitleDisplayMode(.inline) .searchable(text: $model.query, prompt: "Search your public groups") - .scrollEdgeBar(.bottom) { - Button(action: save) { - ButtonStateLabel("Save", state: buttonState) - } - .buttonStyle(.filled) - .disabled(!model.canSave) - .accessibilityIdentifier("edit-featured-groups-save") - .padding(.horizontal, 20) - .padding(.top, 12) - .padding(.bottom, 8) - } .dialog(item: $dialog) .task { await model.loadCandidates() } .onChange(of: model.failure) { _, failure in @@ -83,6 +66,42 @@ struct EditFeaturedGroupsScreen: View { .onDisappear { saveTask?.cancel() } } + /// The list appears whole once the feed is read, rather than growing from the featured groups. + @ViewBuilder + private var content: some View { + switch model.loadState { + case .loading: + ProgressView() + .tint(Color.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded, .failed: + list + } + } + + private var list: some View { + List { + Section { + ForEach(model.visibleCandidates) { group in + row(group) + } + } header: { + ListHeader("\(model.selection.count) of \(FeaturedGroups.limit) selected") + } footer: { + if !model.candidates.isEmpty { + Text("These public groups will appear on your public profile") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20)) + } + .listStyle(.grouped) + .scrollContentBackground(.hidden) + .overlay { emptyState } + } + private func row(_ group: Conversation) -> some View { Button { model.toggle(group.id) diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift index 9944f6ad2..22833bff5 100644 --- a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -74,14 +74,13 @@ struct FeaturedGroupRow: View { .foregroundStyle(Color.textMain) .lineLimit(1) Text(subtitle) - .font(.appTextCaption) + .font(.appTextSmall) .foregroundStyle(Color.textSecondary) - .lineLimit(1) + .lineLimit(2) } Spacer(minLength: 12) trailing } - .frame(minHeight: 66) .task(id: group.picture?.thumbnailBlobID) { await sessionContainer.profileAvatars.load(.chat(group.id), picture: group.picture) } From 23673d5d75f50e3cdb5e4794ce93959bc71e53b6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:16:31 -0400 Subject: [PATCH 5/8] fix(profile): keep the group search field in place and space out favorite groups The picker swapped a spinner for the List while loading, which rebuilt the List and dropped the search field attached to it. The List now stays, with the spinner drawn over it, and the field is pinned with navigationBarDrawer(displayMode: .always) so it shows before any scroll. Profile rows get 16pt between them (was 2pt) and 24pt under the heading (was 12pt). --- .../Profile/EditFeaturedGroupsScreen.swift | 40 ++++++++++--------- .../Profile/FeaturedGroupsSection.swift | 4 +- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift index 2c8e18a49..d29871d87 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -34,7 +34,7 @@ struct EditFeaturedGroupsScreen: View { Background(color: .backgroundMain) { VStack(spacing: 0) { - content + list Button(action: save) { ButtonStateLabel("Save", state: buttonState) @@ -49,7 +49,11 @@ struct EditFeaturedGroupsScreen: View { } .navigationTitle("Favorite Groups") .navigationBarTitleDisplayMode(.inline) - .searchable(text: $model.query, prompt: "Search your public groups") + .searchable( + text: $model.query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search your public groups" + ) .dialog(item: $dialog) .task { await model.loadCandidates() } .onChange(of: model.failure) { _, failure in @@ -66,29 +70,20 @@ struct EditFeaturedGroupsScreen: View { .onDisappear { saveTask?.cancel() } } - /// The list appears whole once the feed is read, rather than growing from the featured groups. - @ViewBuilder - private var content: some View { - switch model.loadState { - case .loading: - ProgressView() - .tint(Color.textSecondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .loaded, .failed: - list - } - } - private var list: some View { List { Section { - ForEach(model.visibleCandidates) { group in - row(group) + // The list appears whole once the feed is read, rather than growing from the + // featured groups. The List itself stays so the search field keeps its place. + if model.loadState != .loading { + ForEach(model.visibleCandidates) { group in + row(group) + } } } header: { ListHeader("\(model.selection.count) of \(FeaturedGroups.limit) selected") } footer: { - if !model.candidates.isEmpty { + if model.loadState != .loading, !model.candidates.isEmpty { Text("These public groups will appear on your public profile") .font(.appTextSmall) .foregroundStyle(Color.textSecondary) @@ -99,7 +94,14 @@ struct EditFeaturedGroupsScreen: View { } .listStyle(.grouped) .scrollContentBackground(.hidden) - .overlay { emptyState } + .overlay { + if model.loadState == .loading { + ProgressView() + .tint(Color.textSecondary) + } else { + emptyState + } + } } private func row(_ group: Conversation) -> some View { diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift index 22833bff5..8131e9d89 100644 --- a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -24,10 +24,10 @@ struct FeaturedGroupsSection: View { .font(.appTextLarge) .foregroundStyle(Color.textMain) .padding(.top, 24) - .padding(.bottom, 12) + .padding(.bottom, 24) .accessibilityAddTraits(.isHeader) - VStack(spacing: 2) { + VStack(spacing: 16) { ForEach(groups) { group in Button { onTap(group.id) } label: { FeaturedGroupRow(group: group) { EmptyView() } From e9f9bea789005c4f8c1832d2041928091027904d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:24:52 -0400 Subject: [PATCH 6/8] fix(profile): blur the group list under a pinned Save button Save is pinned with scrollEdgeBar(.bottom), so rows scroll under it. A new hardScrollEdge(for:) modifier in FlipcashUI swaps the app-wide soft edge for the hard one on this list, which blurs the rows out instead of leaving them readable. The disabled filled style is a 10% tint, so the button gets a backgroundMain backing in its own shape to stop rows showing through it. --- .../Profile/EditFeaturedGroupsScreen.swift | 27 ++++++++++--------- .../FlipcashUI/Modifiers/ScrollEdge.swift | 20 ++++++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift index d29871d87..dc6f12d81 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -33,19 +33,22 @@ struct EditFeaturedGroupsScreen: View { @Bindable var model = model Background(color: .backgroundMain) { - VStack(spacing: 0) { - list - - Button(action: save) { - ButtonStateLabel("Save", state: buttonState) + list + // Rows scroll under Save but are blurred out there, not left readable. + .hardScrollEdge(for: .bottom) + .scrollEdgeBar(.bottom) { + Button(action: save) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + // The disabled fill is translucent; without this the rows show through it. + .background(Color.backgroundMain, in: RoundedRectangle(cornerRadius: Metrics.buttonRadius)) + .disabled(!model.canSave) + .accessibilityIdentifier("edit-featured-groups-save") + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 20) } - .buttonStyle(.filled) - .disabled(!model.canSave) - .accessibilityIdentifier("edit-featured-groups-save") - .padding(.horizontal, 20) - .padding(.top, 12) - .padding(.bottom, 20) - } } .navigationTitle("Favorite Groups") .navigationBarTitleDisplayMode(.inline) diff --git a/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift b/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift index e43fbb126..2bcebfe05 100644 --- a/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift +++ b/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift @@ -20,6 +20,13 @@ extension View { modifier(SoftScrollEdge(edges: edges)) } + /// Uses the hard scroll edge effect on iOS 26+, so content under a bar on `edges` is blurred + /// out rather than fading and staying legible. Overrides the app-wide soft edge for the + /// scrollable views below it. No-op below iOS 26. + public func hardScrollEdge(for edges: Edge.Set) -> some View { + modifier(HardScrollEdge(edges: edges)) + } + /// Hides the top scroll edge effect until the scroll view has moved past `offset`, for a screen /// whose content starts with a full-bleed image under the bar: at rest the effect would only /// darken that image and leave a visible band where it stops. No-op below iOS 26. @@ -52,6 +59,19 @@ private struct SoftScrollEdge: ViewModifier { } } +private struct HardScrollEdge: ViewModifier { + + let edges: Edge.Set + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.scrollEdgeEffectStyle(.hard, for: edges) + } else { + content + } + } +} + private struct HidesTopScrollEdge: ViewModifier { let offset: CGFloat From 1389b1bc655711c1cdb381a7efb79ca1cde60e33 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 16:29:00 -0400 Subject: [PATCH 7/8] fix(profile): drop the hard scroll edge under the group picker's Save The hard edge drew a full-width blurred band with a line across it behind the button. The picker goes back to the app-wide soft edge, and the button's own backing keeps rows from showing through it. hardScrollEdge had no other caller, so it goes too. --- .../Profile/EditFeaturedGroupsScreen.swift | 2 -- .../FlipcashUI/Modifiers/ScrollEdge.swift | 20 ------------------- 2 files changed, 22 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift index dc6f12d81..58ab3b392 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditFeaturedGroupsScreen.swift @@ -34,8 +34,6 @@ struct EditFeaturedGroupsScreen: View { Background(color: .backgroundMain) { list - // Rows scroll under Save but are blurred out there, not left readable. - .hardScrollEdge(for: .bottom) .scrollEdgeBar(.bottom) { Button(action: save) { ButtonStateLabel("Save", state: buttonState) diff --git a/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift b/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift index 2bcebfe05..e43fbb126 100644 --- a/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift +++ b/FlipcashUI/Sources/FlipcashUI/Modifiers/ScrollEdge.swift @@ -20,13 +20,6 @@ extension View { modifier(SoftScrollEdge(edges: edges)) } - /// Uses the hard scroll edge effect on iOS 26+, so content under a bar on `edges` is blurred - /// out rather than fading and staying legible. Overrides the app-wide soft edge for the - /// scrollable views below it. No-op below iOS 26. - public func hardScrollEdge(for edges: Edge.Set) -> some View { - modifier(HardScrollEdge(edges: edges)) - } - /// Hides the top scroll edge effect until the scroll view has moved past `offset`, for a screen /// whose content starts with a full-bleed image under the bar: at rest the effect would only /// darken that image and leave a visible band where it stops. No-op below iOS 26. @@ -59,19 +52,6 @@ private struct SoftScrollEdge: ViewModifier { } } -private struct HardScrollEdge: ViewModifier { - - let edges: Edge.Set - - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.scrollEdgeEffectStyle(.hard, for: edges) - } else { - content - } - } -} - private struct HidesTopScrollEdge: ViewModifier { let offset: CGFloat From 1f5fcfd9d33c41954db5259d65072a5c275eb93f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 6 Oct 2026 18:52:23 -0400 Subject: [PATCH 8/8] fix(profile): read the favorite groups inset from ProfileHeaderMetrics #993 moved the profile inset to ProfileHeaderMetrics and gave ProfileHeaderView a fourth generic parameter, so the old ProfileHeaderView.inset no longer compiles. --- Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift index 8131e9d89..7217e6e79 100644 --- a/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift +++ b/Flipcash/Core/Screens/Profile/FeaturedGroupsSection.swift @@ -41,7 +41,7 @@ struct FeaturedGroupsSection: View { } } } - .padding(.horizontal, ProfileHeaderView.inset) + .padding(.horizontal, ProfileHeaderMetrics.inset) .accessibilityIdentifier("profile-featured-groups") } }