Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 17 additions & 1 deletion .claude/docs/hard-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,28 @@ app can fix itself belongs in the service files that wrap the generated code.

## Database Schema Changes

**Bump `SQLiteVersion` in Info.plist on every schema change.** The app does not run migrations — when the version number increases, the database is deleted and rebuilt from server data on next login (`SessionAuthenticator.initializeDatabase`). This means:
**Bump `Database.schemaVersion` on every change to what the store writes.** The app does not run
migrations — when the version number increases, the database is deleted and rebuilt from server data
on next login (`SessionAuthenticator.initializeDatabase`). This means:

- Adding/removing tables or columns → bump version
- Changing which table a query reads from → bump version if the old schema can't satisfy the new query
- **Changing the encoding of a value already stored in a column → bump version.** The column's SQL
type is unchanged, so nothing about `Schema.swift` looks different, but rows written by an earlier
build no longer decode. Adding a non-optional property to a `Codable` struct persisted as a JSON
blob is the usual way in: the synthesized decoder throws `keyNotFound` on an older row, and because
these reads are `try?`, the whole value silently becomes `nil` — losing every *other* field in the
blob too, not just the new one.
- No migration code needed, but all data must be recoverable from server

The version lives in code, not `Info.plist`. It moved because the notification service extension needs
the same number to decide whether the store on disk is one it understands, and an extension cannot read
the app's `Info.plist`. Both targets link `FlipcashStore`, so they cannot disagree.

Resist making a decoder tolerate the older shape (`decodeIfPresent`, a default value, a custom
`init(from:)`). It preserves a stale value where the bump would fetch the correct one, and it is a
migration in all but name — the thing this design exists to avoid.

## Logging: Variables Go in Metadata

**All variable data must go in structured `metadata`. The message string is a constant, free-form description.** Two reasons, in order of importance:
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ it before touching the relevant area.
- **Exhaustive switches** — Prefer `switch` over `if case` for enums so the compiler flags new cases.
- **Modernize incrementally** — Use modern Swift/SwiftUI APIs in net-new/isolated code; don't refactor working code just to modernize. One observation system per class.
- **Generated protos** — `FlipcashAPI` only re-exports the published contract packages; there is no generated code to edit here. Change the wrapping service files instead.
- **Database schema** — Bump `SQLiteVersion` in Info.plist on every schema change (no migrations; DB is rebuilt from server).
- **Database schema** — Bump `Database.schemaVersion` on every change to what the store *writes*, including the encoding of a persisted blob (no migrations; DB is rebuilt from server).
- **Logging** — Message string is a constant; every variable goes in structured `metadata`. Never log proto blobs whole.
- **Error reporting** — Call `ErrorReporting.captureError(...)` unconditionally; classify via `ServerError.reportingLevel`, never gate at the call site. Best-effort chatter never reports.
- **Form validation** — Validate free-form input through the `Validator` family and submit the validator's `Output`, never inline regex/trim; keypad amounts parse only via `AmountValidator`.
Expand Down
22 changes: 22 additions & 0 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,28 @@ final class ConversationController {
refreshFeedPreview(for: conversation.id)
}

/// 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.
///
/// Applies the two fields through the very mutators the `titleChanged` / `pictureChanged` stream
/// events apply — deliberately, rather than `.metadataRefresh`. Two reasons, both load-bearing:
///
/// - `.metadataRefresh` upserts the whole conversation, replacing the held row outright. The
/// edit response carries chat metadata, not the locally-held membership flag, viewer state or
/// roster, so a wholesale replace would drop them.
/// - Both mutators are last-write-wins assignments, so when the server's own echo of this edit
/// arrives on the stream it re-applies identical values. The local apply and the echo are the
/// same write, which is what keeps an edit from landing twice.
func applyEdit(_ conversation: Conversation) {
if let title = conversation.title {
store.applyTitleChanged(title, in: conversation.id)
}
if let picture = conversation.picture {
store.applyPictureChanged(picture, in: conversation.id)
}
persistConversation(conversation.id)
}

/// Leaves a group. The chat stays in the store — the screen the user left from is still on top and
/// needs it to render the gate — but drops out of ``joinedGroups``, so it leaves the Chats list.
///
Expand Down
18 changes: 16 additions & 2 deletions Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ extension AppRouter {
case userProfile(UserID)
/// A group chat's own profile, pushed from its head card or its navigation title.
case chatProfile(ConversationID)
/// 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`.
case editGroupName(ConversationID)
/// Replacing a group's picture, pushed from the Picture row of `editGroup`.
case editGroupPicture(ConversationID)

/// The stack this destination naturally belongs in. Cross-stack
/// navigation uses this to know which sheet to present, or which tab
Expand All @@ -132,7 +139,8 @@ extension AppRouter {
return .you
case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup,
.tipConversation, .tipConversationWithKeyboard, .tipConversationForUser,
.userProfile, .chatProfile:
.userProfile, .chatProfile,
.editGroup, .editGroupName, .editGroupPicture:
return .tips
}
}
Expand Down Expand Up @@ -182,6 +190,9 @@ extension AppRouter {
case .tipConversationForUser: "tipConversationForUser"
case .userProfile: "userProfile"
case .chatProfile: "chatProfile"
case .editGroup: "editGroup"
case .editGroupName: "editGroupName"
case .editGroupPicture: "editGroupPicture"
}
}

Expand All @@ -203,7 +214,10 @@ extension AppRouter {
return activity.id.base58
case .tipConversation(let conversationID),
.tipConversationWithKeyboard(let conversationID),
.chatProfile(let conversationID):
.chatProfile(let conversationID),
.editGroup(let conversationID),
.editGroupName(let conversationID),
.editGroupPicture(let conversationID):
return conversationID.description
case .userProfile(let userID),
.tipConversationForUser(let userID):
Expand Down
18 changes: 18 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct DestinationView: View {

@Environment(Container.self) private var container
@Environment(SessionContainer.self) private var sessionContainer
@Environment(ConversationController.self) private var conversationController

let destination: AppRouter.Destination

Expand Down Expand Up @@ -196,6 +197,23 @@ struct DestinationView: View {
case .chatProfile(let conversationID):
ChatProfileScreen(conversationID: conversationID)
.id(conversationID)

case .editGroup(let conversationID):
EditGroupScreen(conversationID: conversationID)
.id(conversationID)

case .editGroupName(let conversationID):
// Seeded here, like `.changeDisplayName` above, so the field opens on the name it is
// about to replace instead of animating it in over the push.
EditGroupNameScreen(
conversationID: conversationID,
currentTitle: conversationController.conversation(withID: conversationID)?.title ?? ""
)
.id(conversationID)

case .editGroupPicture(let conversationID):
EditGroupPictureScreen(conversationID: conversationID)
.id(conversationID)
}
}
}
Expand Down
29 changes: 29 additions & 0 deletions Flipcash/Core/Screens/Profile/ChatProfileScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ struct ChatProfileScreen: View {
return conversationController.isMember(of: conversation)
}

/// Whether to offer the overflow menu at all: it holds Edit and nothing else, so with the edit
/// permission withheld there is no menu to draw.
///
/// Reads ``Conversation/canEdit`` — the server's answer — and nothing beside it. Membership and
/// creator identity are deliberately not consulted; see that property.
private var canEdit: Bool {
conversation?.canEdit ?? false
}

var body: some View {
Background(color: .backgroundMain) {
VStack(spacing: 16) {
Expand Down Expand Up @@ -125,6 +134,26 @@ struct ChatProfileScreen: View {
}
.navigationTitle("")
.toolbarTitleDisplayMode(.inline)
.toolbar {
if canEdit {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
router.push(.editGroup(conversationID))
} label: {
Label {
Text("Edit")
} icon: {
Image.system(.pencil)
}
}
} label: {
Image.system(.ellipsis)
}
.accessibilityIdentifier("chat-profile-overflow")
}
}
}
.dialog(item: $dialogItem)
.sheet(isPresented: $isInviting) {
GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// DialogItem+GroupChange.swift
// Flipcash
//

import FlipcashUI

extension DialogItem {

/// The group fields the Edit Group list 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
/// there is no first-time-setup case to exempt — every edit here is a replacement.
enum GroupField {

case name
case picture

/// Group-qualified rather than borrowing the row's bare "Name" / "Picture", 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"
}
}

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"
}
}
}

/// Confirms replacing a group field. Raised on Save, after validation, so the user is never
/// asked to confirm a name the screen is about to reject anyway.
///
/// Alerts rather than informs, which is where this parts company with the profile
/// confirmations: those weigh whether the user can get their own old value back, and only the
/// username can't. Here the edit lands on everyone in the group at once, and no later undo
/// takes back what they have already seen.
///
/// Untracked, for the same reason the profile confirmations are: a user changing a group they
/// are permitted to edit is not an error worth an analytics event.
static func confirmGroupChange(
_ field: GroupField,
onConfirm: @escaping () -> Void
) -> DialogItem {
.alert(title: "Change \(field.title)?", subtitle: field.subtitle) {
DialogAction.destructive("Change \(field.title)", action: onConfirm)
DialogAction.cancel()
}
}
}
Loading
Loading