Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
dd2dd8c
feat(settings): Settings screen behind a gear on the You tab
bmc08gt Oct 6, 2026
3186981
chore(settings): drop My Account from test wording, document AccountI…
bmc08gt Oct 6, 2026
fffc24c
feat(you): own profile layout; profile card behind Share
bmc08gt Oct 6, 2026
b657c1f
fix(you): keep banner control ids, close profile card from router, ow…
bmc08gt Oct 6, 2026
c0a2c10
feat(profile): other-user profile with overflow menu and pinned action
bmc08gt Oct 6, 2026
338c5a5
fix(profile): open chat reliably after paying and align overflow actions
bmc08gt Oct 6, 2026
8cea0f0
fix(profile): match the Figma layout for both profiles
bmc08gt Oct 6, 2026
b3fec19
fix(profile): system toolbar and soft edges, single Mute entry
bmc08gt Oct 6, 2026
de9dcf4
fix(profile): use the app's type, colour and sheet components
bmc08gt Oct 6, 2026
1276dd9
fix(profile): hide the top scroll edge until the banner scrolls under…
bmc08gt Oct 6, 2026
0184913
fix(profile): put the encryption notice above the pinned button
bmc08gt Oct 6, 2026
b01727a
fix(profile): move the Blocked and Muted badges into the action row
bmc08gt Oct 6, 2026
07e09ac
fix(profile): flat 38pt header buttons instead of glass
bmc08gt Oct 6, 2026
efcd20a
fix(profile): set the bio in the app's message font
bmc08gt Oct 6, 2026
02b37bd
fix(profile): space the stats card label from its value to match the …
bmc08gt Oct 6, 2026
56d06de
fix(profile): raise toasts above the profile's pinned chat button
bmc08gt Oct 6, 2026
c84475e
fix(toast): rise a fixed 56pt when no tab bar shows
bmc08gt Oct 6, 2026
2d45601
feat(profile): Edit Profile with bio and cover
bmc08gt Oct 6, 2026
0f7bbf7
refactor(profile): open Edit Profile from the You tab and reuse the p…
bmc08gt Oct 6, 2026
c1da07f
feat(profile): lay out Edit Profile as Figma's cover card and field c…
bmc08gt Oct 6, 2026
3646324
fix(profile): set Edit Profile card labels in appTextCaption
bmc08gt Oct 6, 2026
75096c6
feat(profile): make the Change cover chip Liquid Glass on iOS 26
bmc08gt Oct 6, 2026
ae47d4d
feat(profile): make Change cover its own darker, interactive glass bu…
bmc08gt Oct 6, 2026
91bd442
feat(profile): move Download to the toolbar and Close to the bottom o…
bmc08gt Oct 6, 2026
feab207
feat(profile): reveal the profile card with the scanned-card spring
bmc08gt Oct 6, 2026
bdaf5bb
fix(profile): fade the profile card out at full size on close
bmc08gt Oct 6, 2026
9c85f75
fix(profile): draw the profile card inside the You tab so the gear's …
bmc08gt Oct 6, 2026
88caa60
test(profile): wait for the gear to return after closing the profile …
bmc08gt Oct 6, 2026
e885f16
test(profile): back out of Edit Profile in one step now that it opens…
bmc08gt Oct 6, 2026
28e684b
fix(profile): focus editor fields once the push settles
bmc08gt Oct 6, 2026
8cd5ce8
fix(settings): uppercase section headings and gate Account Info behin…
bmc08gt Oct 6, 2026
780c4d0
fix(profile): show the effective minimum to chat in Edit Profile
bmc08gt Oct 6, 2026
a3ad162
feat(routing): open the profile until the DM exists
bmc08gt Oct 6, 2026
50abae4
refactor(chat): remove the paid-DM gate from the conversation
bmc08gt Oct 6, 2026
9d6275e
test(tips): expect the tip link to land on the recipient's profile
bmc08gt Oct 6, 2026
c71ca01
feat(profile): call it a profile card in user-facing copy
bmc08gt Oct 6, 2026
16899b9
feat(wallet): say "Send your first message" on the scan tutorial step
bmc08gt Oct 6, 2026
b662c90
revert(username): keep UsernameProgressCard for possible reuse
bmc08gt Oct 6, 2026
70d137b
Merge remote-tracking branch 'origin/main' into feat/profile-featured…
bmc08gt Oct 6, 2026
4ab56d5
feat(profile): favorite public groups on profiles
bmc08gt Oct 6, 2026
6ce9374
fix(profile): draw favorite public groups as Figma does
bmc08gt Oct 6, 2026
e7aadf8
fix(profile): match the favorite groups picker to Figma
bmc08gt Oct 6, 2026
fb89268
fix(profile): tighten the favorite groups list and its tokens
bmc08gt Oct 6, 2026
53bdbb0
fix(profile): keep the group search field in place and space out favo…
bmc08gt Oct 6, 2026
32b40db
fix(profile): blur the group list under a pinned Save button
bmc08gt Oct 6, 2026
e824272
fix(profile): drop the hard scroll edge under the group picker's Save
bmc08gt Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .claude/plans/2026-10-06-featured-groups.md
Original file line number Diff line number Diff line change
@@ -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`.
11 changes: 8 additions & 3 deletions .maestro/you-tab-download.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Drives the You tab's export path end to end, once per format: card page ->
# Download -> pick a format -> the system share sheet holding the written file.
# Drives the You tab's export path end to end, once per format: Share menu ->
# Show Profile Card -> Download -> pick a format -> the system share sheet holding the written file.
appId: com.flipcash.app.ios
---
- launchApp
- tapOn: "You"
- assertVisible: "Full Screen"
- tapOn:
id: "you-share"
- tapOn: "Show Profile Card"

# --- SVG ---
- tapOn:
Expand All @@ -23,6 +25,9 @@ appId: com.flipcash.app.ios
# Relaunching is the cheapest way to get the system sheet off screen.
- launchApp
- tapOn: "You"
- tapOn:
id: "you-share"
- tapOn: "Show Profile Card"
- tapOn:
id: "you-download-button"
- tapOn:
Expand Down
3 changes: 3 additions & 0 deletions Flipcash/Core/Controllers/BetaFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,12 @@ final class ConversationController {
}
}

/// The ID of the tip DM with `userID`, or nil when the chat hasn't started. The single "chat
/// started" rule: the visible DM. Hidden chats are excluded, and a block takes precedence anyway.
func tipDMID(withUserID userID: UserID) -> ConversationID? {
tipDM(withUserID: userID)?.id
}

private func contactName(for conversationID: ConversationID) -> String? {
guard let name = contactNaming.contactDisplayName(forDMChat: conversationID),
!name.isEmpty else {
Expand Down
8 changes: 4 additions & 4 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,8 @@ struct DeepLinkAction {
}

/// Whether a person link names the viewer, answerable without a lookup only when the id or the
/// handle is already known locally. A link to the viewer keeps opening their own tip card, as it
/// did before person links opened a profile.
/// handle is already known locally. A link to the viewer opens their own profile, the same as
/// scanning their own code.
static func isOwnLink(
_ identifier: ProfileIdentifier,
selfUserID: UserID,
Expand Down Expand Up @@ -326,7 +326,7 @@ struct DeepLinkAction {

private static func showLookupFailed(isScan: Bool, on session: Session) {
session.dialogItem = .error(
title: isScan ? "Couldn't Open Tip Card" : "Couldn't Open Profile",
title: isScan ? "Couldn't Open Profile Card" : "Couldn't Open Profile",
subtitle: "Please check your connection and try again"
)
}
Expand Down Expand Up @@ -425,7 +425,7 @@ struct DeepLinkAction {
if let container = sessionAuthenticator.loggedInContainer {
Analytics.deeplinkRouted(kind: kind)
// `begin` owns the own-id case: a self link lands on the user's
// own tip card rather than starting a tip.
// own profile rather than starting a tip.
container.tipFlow.begin(userID: userID)
}

Expand Down
34 changes: 30 additions & 4 deletions Flipcash/Core/Controllers/ProfileAvatarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,23 @@ final class ProfileAvatarStore {
enum AvatarSubject: Hashable, Sendable {
case user(UserID)
case chat(ConversationID)
/// A user's cover banner. Authorizes like the user's avatar but holds the full-size blob.
case cover(UserID)

/// The context this subject's blobs authorize through.
var accessContext: BlobAccessContext {
switch self {
case .user(let userID): .userProfile(userID)
case .chat(let conversationID): .chatProfile(conversationID)
case .cover(let userID): .userProfile(userID)
}
}

/// The blob of `picture` this subject renders: avatars use the thumbnail, a cover the original.
func blobID(for picture: ProfilePicture) -> BlobID {
switch self {
case .user, .chat: picture.thumbnailBlobID
case .cover: picture.blobID
}
}
}
Expand All @@ -43,6 +54,7 @@ final class ProfileAvatarStore {
/// merely turned away could not learn that the fetch it skipped had failed, and nothing retried.
@ObservationIgnored private var inFlight: [AvatarSubject: Task<Void, Never>] = [:]
@ObservationIgnored private let cache: BlobCache
@ObservationIgnored private let coverCache: BlobCache
@ObservationIgnored private let mintURL: (BlobID, AvatarSubject) async throws -> URL?
@ObservationIgnored private let fetch: (URL) async throws -> Data

Expand All @@ -52,19 +64,27 @@ final class ProfileAvatarStore {
/// - fetch: downloads the bytes at a URL.
init(
cache: BlobCache = .profilePictures,
coverCache: BlobCache = .profileCovers,
mintURL: @escaping (BlobID, AvatarSubject) async throws -> URL?,
fetch: @escaping (URL) async throws -> Data = { url in
try await URLSession.shared.data(from: url).0
}
) {
self.cache = cache
self.coverCache = coverCache
self.mintURL = mintURL
self.fetch = fetch
}

convenience init(flipClient: FlipClient, owner: KeyPair, cache: BlobCache = .profilePictures) {
convenience init(
flipClient: FlipClient,
owner: KeyPair,
cache: BlobCache = .profilePictures,
coverCache: BlobCache = .profileCovers
) {
self.init(
cache: cache,
coverCache: coverCache,
mintURL: { blobID, subject in
try await flipClient.blobDownloadURL(
blobID: blobID,
Expand All @@ -91,16 +111,17 @@ final class ProfileAvatarStore {
await load(.user(userID), picture: picture)
}

/// Makes a subject's current thumbnail available to ``data(for:)``.
/// Makes a subject's current picture available to ``data(for:)``.
///
/// Returns without a round trip when the bytes are already in memory or on disk for that exact
/// blob, and joins the fetch already running for that subject rather than starting a second one.
func load(_ subject: AvatarSubject, picture: ProfilePicture?) async {
guard let blobID = picture?.thumbnailBlobID else { return }
guard let blobID = picture.map(subject.blobID(for:)) else { return }

// A different blob under the same id is a changed picture, not a cache hit.
if blobBySubject[subject] == blobID, dataBySubject[subject] != nil { return }

let cache = cache(for: subject)
if let cached = cache.data(for: blobID) {
store(cached, subject: subject, blobID: blobID)
return
Expand All @@ -122,13 +143,18 @@ final class ProfileAvatarStore {
await task.value
}

/// Covers hold the full-size blob, so they get their own budget rather than crowding out thumbnails.
private func cache(for subject: AvatarSubject) -> BlobCache {
if case .cover = subject { coverCache } else { cache }
}

/// Download URLs expire, so one is minted per fetch and never stored.
private func download(blobID: BlobID, subject: AvatarSubject) async {
do {
guard let url = try await mintURL(blobID, subject) else { return }

let data = try await fetch(url)
cache.write(data, for: blobID)
cache(for: subject).write(data, for: blobID)
store(data, subject: subject, blobID: blobID)
} catch {
guard !Task.isCancelled else { return }
Expand Down
56 changes: 29 additions & 27 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,34 @@ extension AppRouter {
case usdcDepositAddress

// Settings flow
case settingsMyAccount
/// The display name on its own, edited from My Account. The full
/// The Settings list, opened from the gear on the You tab.
case settings
/// The account's phone, email, user ID and public key, opened from Settings.
case accountInfo
/// The signed-in user's profile fields in one list, opened from the You tab's Edit Profile button.
case editProfile
/// The bio on its own, pushed from Edit Profile.
case editBio
/// The public groups shown on the signed-in user's profile, chosen from Edit Profile.
case editFeaturedGroups
/// The cover banner on its own, pushed from Edit Profile.
case changeCoverPicture
/// The display name on its own, edited from Settings. The full
/// profile-setup flow starts on the same screen but carries on to the
/// tip card; this one returns to the settings list.
case changeDisplayName
/// The profile picture on its own, changed from the You tab's checklist.
/// The full profile-setup flow uses `.profilePhoto`, which carries on to
/// the tip card; this one returns to the screen that opened it.
case changeProfilePicture
/// The public handle, claimed from the You page or changed from My
/// Account. One destination for both: the screen seeds itself from the
/// The public handle, claimed from the You page or changed from
/// Settings. One destination for both: the screen seeds itself from the
/// handle already on the profile, so there is nothing to distinguish.
case username(Username?)
/// The minimum a tipper must pay to open a DM. `isSetupStep` marks the
/// You tab's checklist apart from a lone edit in My Account; it reaches
/// You tab's checklist apart from a lone edit in Settings; it reaches
/// the payload only, since the screen is the same from either.
case setMinimumTip(isSetupStep: Bool)
case settingsAdvancedFeatures
case settingsAdvancedBetaFeatures
case settingsAppSettings
case settingsAccountSelection
Expand Down Expand Up @@ -103,14 +113,6 @@ extension AppRouter {
/// trace shows "post-tip, keyboard up" distinctly, and so the ordinary
/// tip-list / push-notification opens stay keyboard-closed untouched.
case tipConversationWithKeyboard(ConversationID)
/// A tip DM named by its counterpart rather than its chat id — the
/// username lookup's destination. The chat is created by the first tip,
/// so before then there is no id to push; the screen derives one.
case tipConversationForUser(UserID)
/// Same chat as `tipConversationForUser`, opened with Send Cash already started — the
/// profile's Send Cash button. A sibling case for the same reason as
/// `tipConversationWithKeyboard`: the trace shows the send-first open distinctly.
case tipConversationForUserSendingCash(UserID)
/// A person's Flipcash profile, pushed from a tip DM's title/card or a group member's face;
/// hosts the Block action. `origin` decides whether it offers a way into the DM.
case userProfile(UserID, origin: UserProfileOrigin)
Expand Down Expand Up @@ -141,14 +143,14 @@ extension AppRouter {
.buyCurrency, .convertCurrency,
.withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress:
return .balance
case .settingsMyAccount, .changeDisplayName, .changeProfilePicture, .username,
.setMinimumTip, .settingsAdvancedFeatures,
case .settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture,
.changeDisplayName, .changeProfilePicture, .username,
.setMinimumTip,
.settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection,
.settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw:
return .you
case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup,
.tipConversation, .tipConversationWithKeyboard, .tipConversationForUser,
.tipConversationForUserSendingCash, .userProfile, .chatProfile,
.tipConversation, .tipConversationWithKeyboard, .userProfile, .chatProfile,
.editGroup, .editGroupName, .editGroupPicture, .archivedChats:
return .tips
}
Expand Down Expand Up @@ -176,12 +178,16 @@ extension AppRouter {
case .withdrawCurrency: "withdrawCurrency"
case .usdcDepositEducation: "usdcDepositEducation"
case .usdcDepositAddress: "usdcDepositAddress"
case .settingsMyAccount: "settingsMyAccount"
case .settings: "settings"
case .accountInfo: "accountInfo"
case .editProfile: "editProfile"
case .editBio: "editBio"
case .editFeaturedGroups: "editFeaturedGroups"
case .changeCoverPicture: "changeCoverPicture"
case .changeDisplayName: "changeDisplayName"
case .changeProfilePicture: "changeProfilePicture"
case .username: "username"
case .setMinimumTip: "setMinimumTip"
case .settingsAdvancedFeatures: "settingsAdvancedFeatures"
case .settingsAdvancedBetaFeatures: "settingsAdvancedBetaFeatures"
case .settingsAppSettings: "settingsAppSettings"
case .settingsAccountSelection: "settingsAccountSelection"
Expand All @@ -197,8 +203,6 @@ extension AppRouter {
case .newPublicGroup: "newPublicGroup"
case .tipConversation: "tipConversation"
case .tipConversationWithKeyboard: "tipConversationWithKeyboard"
case .tipConversationForUser: "tipConversationForUser"
case .tipConversationForUserSendingCash: "tipConversationForUserSendingCash"
case .userProfile: "userProfile"
case .chatProfile: "chatProfile"
case .editGroup: "editGroup"
Expand Down Expand Up @@ -232,9 +236,7 @@ extension AppRouter {
.editGroupName(let conversationID),
.editGroupPicture(let conversationID):
return conversationID.description
case .userProfile(let userID, _),
.tipConversationForUser(let userID),
.tipConversationForUserSendingCash(let userID):
case .userProfile(let userID, _):
return userID.uuidString
case .username(let username):
return username?.value
Expand All @@ -243,8 +245,8 @@ extension AppRouter {
case .activity,
.discoverCurrencies, .currencyCreationSummary, .currencyCreationWizard,
.usdcDepositEducation, .usdcDepositAddress,
.settingsMyAccount, .changeDisplayName, .changeProfilePicture,
.settingsAdvancedFeatures,
.settings, .accountInfo, .editProfile, .editBio, .editFeaturedGroups, .changeCoverPicture,
.changeDisplayName, .changeProfilePicture,
.settingsAdvancedBetaFeatures, .settingsAppSettings, .settingsAccountSelection,
.settingsApplicationLogs, .blockedUsers, .accessKey, .withdraw,
.profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup,
Expand Down
Loading