Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 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
5b05ed0
feat(chat): send cover_picture and description through editChat
bmc08gt Oct 6, 2026
79ad9ef
refactor(profile): key the profile header and cover on what they render
bmc08gt Oct 6, 2026
818a1ca
Merge remote-tracking branch 'origin/feat/edit-chat-cover-picture' in…
bmc08gt Oct 6, 2026
4737112
feat(chat): rebuild Edit Group in Edit Profile's shape
bmc08gt Oct 6, 2026
30f84e2
Merge origin/main into feat/edit-group-refresh
bmc08gt Oct 6, 2026
66bd27b
Merge remote-tracking branch 'origin/main' into feat/edit-group-refresh
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
8 changes: 6 additions & 2 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1153,9 +1153,10 @@ final class ConversationController {
}

/// Seats the metadata `EditChat` returns for a chat the signed-in user just edited, so its
/// profile and the Chats list show the new title or picture without waiting for anything else.
/// profile and the Chats list show the new title, description, picture or cover without waiting
/// for anything else.
///
/// Applies the two fields through the very mutators the `titleChanged` / `pictureChanged` stream
/// Applies the fields through the very mutators the matching `MetadataUpdate` stream
/// events apply — deliberately, rather than `.metadataRefresh`. Two reasons, both load-bearing:
///
/// - `.metadataRefresh` upserts the whole conversation, replacing the held row outright. The
Expand All @@ -1168,6 +1169,9 @@ final class ConversationController {
if let title = conversation.title {
store.applyTitleChanged(title, in: conversation.id)
}
// Unconditional, unlike the others: the response is the full post-edit metadata, and a
// cleared description arrives as nil, which has to land as a clear.
store.applyDescriptionChanged(conversation.description ?? "", in: conversation.id)
if let picture = conversation.picture {
store.applyPictureChanged(picture, in: conversation.id)
}
Expand Down
17 changes: 13 additions & 4 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,14 @@ extension AppRouter {
/// What an editor can change about a group, pushed from the chat profile's overflow menu.
/// Only reachable while ``Conversation/canEdit`` holds.
case editGroup(ConversationID)
/// Renaming a group, pushed from the Name row of `editGroup`.
/// Renaming a group, pushed from the Group name card of `editGroup`.
case editGroupName(ConversationID)
/// Replacing a group's picture, pushed from the Picture row of `editGroup`.
/// Replacing a group's picture, pushed from the photo on `editGroup`.
case editGroupPicture(ConversationID)
/// Replacing a group's cover banner, pushed from the cover on `editGroup`.
case editGroupCover(ConversationID)
/// Setting or clearing a group's description, pushed from the Description card of `editGroup`.
case editGroupDescription(ConversationID)
/// The list of archived chats, pushed from the Archived row on the Chats tab.
case archivedChats

Expand All @@ -151,7 +155,8 @@ extension AppRouter {
return .you
case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup,
.tipConversation, .tipConversationWithKeyboard, .userProfile, .chatProfile,
.editGroup, .editGroupName, .editGroupPicture, .archivedChats:
.editGroup, .editGroupName, .editGroupPicture, .editGroupCover,
.editGroupDescription, .archivedChats:
return .tips
}
}
Expand Down Expand Up @@ -208,6 +213,8 @@ extension AppRouter {
case .editGroup: "editGroup"
case .editGroupName: "editGroupName"
case .editGroupPicture: "editGroupPicture"
case .editGroupCover: "editGroupCover"
case .editGroupDescription: "editGroupDescription"
case .archivedChats: "archivedChats"
}
}
Expand All @@ -234,7 +241,9 @@ extension AppRouter {
.chatProfile(let conversationID),
.editGroup(let conversationID),
.editGroupName(let conversationID),
.editGroupPicture(let conversationID):
.editGroupPicture(let conversationID),
.editGroupCover(let conversationID),
.editGroupDescription(let conversationID):
return conversationID.description
case .userProfile(let userID, _):
return userID.uuidString
Expand Down
24 changes: 24 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,30 @@ struct DestinationView: View {
case .editGroupPicture(let conversationID):
EditGroupPictureScreen(conversationID: conversationID)
.id(conversationID)

case .editGroupCover(let conversationID):
EditGroupCoverScreen(conversationID: conversationID)
.id(conversationID)

case .editGroupDescription(let conversationID):
// Seeded here, like `.editBio`, so the field opens on the description it is about to
// replace. The response carries the post-edit metadata, so the store is seated from it.
let editor = SessionGroupChatEditor(session: sessionContainer.session, flipClient: container.flipClient)
let conversationController = conversationController
EditGroupDescriptionScreen(model: EditGroupDescriptionModel(
description: conversationController.conversation(withID: conversationID)?.description ?? "",
saving: { edit in
let conversation = try await editor.editChat(
conversationID: conversationID,
title: nil,
description: edit,
pictureBlobID: nil,
coverPictureBlobID: nil
)
conversationController.applyEdit(conversation)
}
))
.id(conversationID)
}
}
}
Expand Down
56 changes: 0 additions & 56 deletions Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -191,59 +191,3 @@ struct EditProfileScreen: View {
}
}
}

/// A tappable card with the field's title over its current value, styled like the profile's stats card.
private struct FieldCard: View {

let title: String
let value: String?
let placeholder: String
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))
}
}
}
64 changes: 64 additions & 0 deletions Flipcash/Core/Screens/Main/Profile/ProfileFieldCard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// ProfileFieldCard.swift
// Flipcash
//

import SwiftUI
import FlipcashCore
import FlipcashUI

/// A tappable card with the field's title over its current value, styled like the profile's stats card.
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.
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))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,36 @@ import FlipcashUI

extension DialogItem {

/// The group fields the Edit Group list can replace.
/// The group fields Edit Group can replace.
///
/// The sibling of ``DialogItem/ProfileField``, and confirmed for a different reason: a profile
/// field is the user's own, while both of these are visible to everyone in the group the moment
/// they save. Neither can be reached before the group exists, so unlike the profile fields
/// field is the user's own, while every one of these is visible to everyone in the group the moment
/// they save. None can be reached before the group exists, so unlike the profile fields
/// there is no first-time-setup case to exempt — every edit here is a replacement.
enum GroupField {

case name
case picture
case cover
case description

/// Group-qualified rather than borrowing the row's bare "Name" / "Picture", which would
/// Group-qualified rather than borrowing the card's bare "Name" / "Description", which would
/// read as the user's own once the dialog covers the screen that gave them context.
var title: String {
switch self {
case .name: "Group Name"
case .picture: "Group Picture"
case .name: "Group Name"
case .picture: "Group Picture"
case .cover: "Group Cover"
case .description: "Group Description"
}
}

var subtitle: String {
switch self {
case .name: "This will change the group name for everyone in it"
case .picture: "This will change the group picture for everyone in it"
case .name: "This will change the group name for everyone in it"
case .picture: "This will change the group picture for everyone in it"
case .cover: "This will change the group cover for everyone in it"
case .description: "This will change the group description for everyone in it"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// DialogItem+GroupImageEdit.swift
// Flipcash
//

import FlipcashCore
import FlipcashUI

private let logger = Logger(label: "flipcash.edit-group-image")

extension DialogItem {

/// Which of a group's two images an upload was for, so a failure names the right one in logs.
enum GroupImage {
case picture
case cover

fileprivate var noun: String {
switch self {
case .picture: "group picture"
case .cover: "group cover"
}
}
}

/// Reports a failed group picture or cover save and returns the dialog that explains it.
///
/// Shared by both editors and mirrors ``NewPublicGroupScreen``'s mapping, so the same server
/// answer reads the same way wherever a group image is submitted.
static func groupImageEditFailed(_ error: Error, image: GroupImage) -> DialogItem {
switch error {
case ErrorEditChat.pictureBlobNotAccepted, ErrorEditChat.coverPictureBlobNotAccepted:
logger.info("Group image not accepted", metadata: ["image": "\(image.noun)"])
ErrorReporting.captureError(error, reason: "Group image not accepted")
return .error(
title: "This Photo Isn't Allowed",
subtitle: "Try a different photo"
)

case ErrorEditChat.denied:
logger.info("Group edit denied", metadata: ["image": "\(image.noun)"])
ErrorReporting.captureError(error, reason: "Group edit denied")
return .error(
title: "You Can't Edit This Group",
subtitle: "Try again later"
)

case ErrorEditChat.notFound:
logger.info("Group edit target not found", metadata: ["image": "\(image.noun)"])
ErrorReporting.captureError(error, reason: "Group edit target not found")
return .error(
title: "This Group No Longer Exists",
subtitle: "It may have been deleted"
)

case let blobError as ErrorBlob:
logger.info("Group image upload failed", metadata: ["image": "\(image.noun)", "error": "\(blobError)"])
ErrorReporting.captureError(blobError, reason: "Group image upload failed", userFacing: true)
return .profilePictureFailed(blobError)

case let encoderError as ImageEncoderError:
logger.error("Failed to encode the group image", metadata: ["image": "\(image.noun)", "error": "\(encoderError)"])
ErrorReporting.captureError(encoderError, reason: "Failed to encode the group image")
return .imageProcessingFailed

default:
logger.error("Failed to edit group image", metadata: ["image": "\(image.noun)", "error": "\(error)"])
ErrorReporting.captureError(error, reason: "Failed to edit group image")
return .error(
title: "Couldn't Save This Photo",
subtitle: "Try again"
)
}
}
}
Loading
Loading