diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 312fb1a3e..fceaeb83c 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -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 @@ -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) } diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index fc67e67c0..f431a689e 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -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 @@ -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 } } @@ -208,6 +213,8 @@ extension AppRouter { case .editGroup: "editGroup" case .editGroupName: "editGroupName" case .editGroupPicture: "editGroupPicture" + case .editGroupCover: "editGroupCover" + case .editGroupDescription: "editGroupDescription" case .archivedChats: "archivedChats" } } @@ -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 diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index b7328c9b7..bb495ce5b 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -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) } } } diff --git a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift index 71949a7fc..86c0aa2f3 100644 --- a/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift +++ b/Flipcash/Core/Screens/Main/Profile/EditProfileScreen.swift @@ -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)) - } - } -} diff --git a/Flipcash/Core/Screens/Main/Profile/ProfileFieldCard.swift b/Flipcash/Core/Screens/Main/Profile/ProfileFieldCard.swift new file mode 100644 index 000000000..a749276ce --- /dev/null +++ b/Flipcash/Core/Screens/Main/Profile/ProfileFieldCard.swift @@ -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)) + } + } +} diff --git a/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupChange.swift b/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupChange.swift index 3a5af9504..f47c5eb79 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupChange.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupChange.swift @@ -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" } } } diff --git a/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupImageEdit.swift b/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupImageEdit.swift new file mode 100644 index 000000000..7b9d7c10b --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupImageEdit.swift @@ -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" + ) + } + } +} diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupCoverScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupCoverScreen.swift new file mode 100644 index 000000000..10e2bca3c --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupCoverScreen.swift @@ -0,0 +1,163 @@ +// +// EditGroupCoverScreen.swift +// Flipcash +// + +import SwiftUI +import UniformTypeIdentifiers +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.edit-group-cover") + +/// Replaces a group's cover banner, pushed from Edit Group's cover. Drawn the way +/// ``ChangeCoverPictureScreen`` draws the profile cover: the banner itself is the picker. +struct EditGroupCoverScreen: View { + + let conversationID: ConversationID + + @Environment(Container.self) private var container + @Environment(SessionContainer.self) private var sessionContainer + @Environment(ConversationController.self) private var conversationController + @Environment(AppRouter.self) private var router + + @State private var model = EditGroupModel() + @State private var isShowingPhotoPicker = false + @State private var isShowingFilePicker = false + @State private var submitTask: Task? + @State private var dialog: DialogItem? + @State private var buttonState: ButtonState = .normal + + private var isSubmitting: Bool { submitTask != nil } + + private var conversation: Conversation? { + conversationController.conversation(withID: conversationID) + } + + var body: some View { + Background(color: .backgroundMain) { + VStack(spacing: 0) { + Menu { + Button("Photo Library", systemImage: "photo.on.rectangle") { isShowingPhotoPicker = true } + Button("Choose File", systemImage: "folder") { isShowingFilePicker = true } + } label: { + ProfileCoverBanner( + cover: .group(conversationID, picture: conversation?.coverPicture), + preview: model.picture + ) + .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius)) + .overlay { + if model.picture == nil && conversation?.coverPicture == nil { + Image(systemName: "plus") + .font(.appDisplayMedium) + .foregroundStyle(Color.textSecondary) + } + } + } + .menuIndicator(.hidden) + .disabled(isSubmitting) + .accessibilityIdentifier("edit-group-cover-picker") + .padding(.top, 20) + + Spacer() + + Button(action: submit) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + // The checkmark hold keeps the cover selected, so the button needs the state to stay + // shut against a second submission. + .disabled(!model.canSavePicture || !buttonState.isNormal || isSubmitting) + .accessibilityIdentifier("edit-group-cover-save-button") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + .navigationTitle("Cover") + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .fullScreenCover(isPresented: $isShowingPhotoPicker) { + ImagePickerWithEditor( + onImagePicked: model.select(picture:), + onDismiss: { isShowingPhotoPicker = false } + ) + .ignoresSafeArea() + } + .fileImporter( + isPresented: $isShowingFilePicker, + allowedContentTypes: [.image], + allowsMultipleSelection: false, + onCompletion: handleFileImport + ) + .onDisappear { submitTask?.cancel() } + } + + /// Save proposes; the dialog commits, as it does for the group's picture. + private func submit() { + guard model.canSavePicture, !isSubmitting else { return } + + dialog = .confirmGroupChange(.cover) { save() } + } + + private func save() { + buttonState = .loading + + submitTask = Task { + defer { submitTask = nil } + + do { + let conversation = try await model.saveCover( + for: conversationID, + using: SessionGroupChatEditor( + session: sessionContainer.session, + flipClient: container.flipClient + ) + ) + + conversationController.applyEdit(conversation) + + buttonState = .success + try? await Task.delay(milliseconds: 500) + + guard !Task.isCancelled else { return } + router.popTopmost() + + } catch { + buttonState = .normal + guard !Task.isCancelled else { return } + dialog = .groupImageEditFailed(error, image: .cover) + } + } + } + + private func handleFileImport(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + logger.info("Group cover file import failed", metadata: ["error": "\(error)"]) + + case .success(let urls): + guard let url = urls.first else { return } + importImage(at: url) + } + } + + private func importImage(at url: URL) { + Task { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + + let image = await Task.detached(priority: .userInitiated) { () -> UIImage? in + guard let data = try? Data(contentsOf: url) else { return nil } + return UIImage(data: data) + }.value + + guard let image else { + dialog = .imageProcessingFailed + return + } + + model.select(picture: image) + } + } +} diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupDescriptionModel.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupDescriptionModel.swift new file mode 100644 index 000000000..a0b8356bd --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupDescriptionModel.swift @@ -0,0 +1,117 @@ +// +// EditGroupDescriptionModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.edit-group-description") + +/// The group description editor's state and its one save. Modelled on ``EditBioModel``. +/// +/// The call is injected: `FlipClient` is a concrete class with a live gRPC channel, so a test has +/// nothing to fake. +@MainActor +@Observable +final class EditGroupDescriptionModel { + + /// What the server refused the description for, shown under the field. Cleared by the next edit. + enum FieldError: Equatable { + case moderated + + var message: String { + switch self { + case .moderated: "This description isn't allowed. Try different wording" + } + } + } + + /// 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 + } + + /// `EditChatRequest.Description.value`'s `max_len`. + static let maxLength = 160 + + /// The description as typed. Editing it clears ``fieldError``. + var text: String { + didSet { + guard text != oldValue else { return } + fieldError = nil + } + } + + private(set) var state: SaveState = .normal + private(set) var fieldError: FieldError? + + /// A failure with no field to blame, shown as a dialog. Set to nil once shown. + var failure: Error? + + @ObservationIgnored private let initialText: String + @ObservationIgnored private let validator = LengthValidator(maxLength: maxLength) + @ObservationIgnored private let saving: (ConversationDescriptionEdit) async throws -> Void + + /// - Parameters: + /// - description: the description the group carries now. + /// - saving: submits the edit and seats the chat's post-edit metadata. + init( + description: String, + saving: @escaping (ConversationDescriptionEdit) async throws -> Void + ) { + self.text = description + self.initialText = description + self.saving = saving + } + + /// How many more characters fit, negative once over the limit. + var remaining: Int { + Self.maxLength - text.count + } + + /// Whether Save is enabled. Clearing the description to empty is a change like any other. + var canSave: Bool { + state == .normal && submission != nil && submission != Self.trim(initialText) + } + + /// The description as it would be submitted, or nil while it is over the limit. Empty is valid, + /// unlike for `LengthValidator`, which rejects blank input: clearing it is an edit. + private var submission: String? { + let trimmed = Self.trim(text) + return trimmed.isEmpty ? trimmed : validator.validate(trimmed) + } + + private static func trim(_ string: String) -> String { + string.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Submits the description, as a clear when it is empty. Failures land in ``fieldError`` or + /// ``failure``. + func save() async { + guard canSave, let submission else { return } + + state = .saving + fieldError = nil + + do { + try await saving(submission.isEmpty ? .clear : .set(submission)) + state = .saved + + } catch ErrorEditChat.descriptionModerated(let category) { + state = .normal + logger.info("Group description moderation denied", metadata: ["category": "\(category)"]) + fieldError = .moderated + + } catch { + state = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set group description", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set group description") + failure = error + } + } +} diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupDescriptionScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupDescriptionScreen.swift new file mode 100644 index 000000000..cd5f8731e --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupDescriptionScreen.swift @@ -0,0 +1,111 @@ +// +// EditGroupDescriptionScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Editing a group's description on its own, pushed from Edit Group's Description card. Drawn like +/// ``EditBioScreen``; an empty description is a clear. +struct EditGroupDescriptionScreen: View { + + @Environment(AppRouter.self) private var router + + @State private var model: EditGroupDescriptionModel + @State private var dialog: DialogItem? + @State private var saveTask: Task? + + @FocusState private var isFocused: Bool + + /// Takes the model rather than building it, so the field opens on the description it is about + /// to replace instead of animating it in over the push. + init(model: EditGroupDescriptionModel) { + _model = State(initialValue: model) + } + + private var buttonState: ButtonState { + switch model.state { + case .normal: .normal + case .saving: .loading + case .saved: .success + } + } + + var body: some View { + @Bindable var model = model + + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 0) { + TextField("Add a description", text: $model.text, axis: .vertical) + .font(.appDisplayXS) + .foregroundStyle(Color.textMain) + .lineLimit(3...8) + .focused($isFocused) + .disabled(model.state != .normal) + .accessibilityIdentifier("edit-group-description-field") + .padding(.top, 20) + + Text("\(model.remaining)") + .font(.appTextSmall) + .foregroundStyle(model.remaining < 0 ? Color.textError : Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.top, 8) + .accessibilityIdentifier("edit-group-description-counter") + + if let fieldError = model.fieldError { + Text(fieldError.message) + .font(.appTextSmall) + .foregroundStyle(Color.textError) + .padding(.top, 8) + .accessibilityIdentifier("edit-group-description-error") + } + + Spacer() + + Button(action: submit) { + ButtonStateLabel("Save", state: buttonState) + } + .buttonStyle(.filled) + .disabled(!model.canSave || saveTask != nil) + .accessibilityIdentifier("edit-group-description-save") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .navigationTitle("Description") + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .focusAfterPush($isFocused) + .onChange(of: model.failure == nil) { _, isNil in + guard !isNil else { return } + model.failure = nil + dialog = .error(title: "Couldn't Save the Description", subtitle: "Try again") + } + // Leaving the screen abandons the submission: its only continuation is a pop. + .onDisappear { saveTask?.cancel() } + } + + /// Save proposes; the dialog commits, since the description changes for everyone in the group. + private func submit() { + guard model.canSave, saveTask == nil else { return } + isFocused = false + dialog = .confirmGroupChange(.description) { save() } + } + + 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/Profile/Edit Group/EditGroupModel.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupModel.swift index 4b0fee5ed..f256ccf94 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupModel.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupModel.swift @@ -16,8 +16,9 @@ struct EditGroupIncomplete: Error {} /// /// One instance per edit rather than one shared across the Edit list's rows: `EditChat` treats /// every field as *unset means unchanged*, so a name edit and a picture edit are two independent -/// partial updates. ``saveTitle(for:using:)`` and ``savePicture(for:using:)`` each send only their -/// own field, which is what keeps editing the name from clearing the picture. +/// partial updates. ``saveTitle(for:using:)``, ``savePicture(for:using:)`` and +/// ``saveCover(for:using:)`` each send only their own field, which is what keeps editing the name +/// from clearing the picture. @MainActor @Observable final class EditGroupModel { @@ -78,8 +79,8 @@ final class EditGroupModel { return validatedTitle != currentTitle } - /// Whether Save is enabled for the picture: one has been picked and nothing is in flight. The - /// group's existing picture is not a submission, so this stays shut until the user picks. + /// Whether Save is enabled for the picture or the cover: one has been picked and nothing is in + /// flight. The group's existing image is not a submission, so this stays shut until the user picks. var canSavePicture: Bool { !isSaving && picture != nil } @@ -136,6 +137,31 @@ final class EditGroupModel { } } + /// Uploads the picked picture as the group's cover, waits for the server to finalize it, then + /// sends it alone and returns the chat's post-edit metadata. + /// + /// The same upload as ``savePicture(for:using:)``, landing in `cover_picture` instead, so the + /// group's own picture and title are left untouched. Untracked: the shared analytics schema + /// has no cover field yet, and recording it as a picture edit would miscount both. + func saveCover(for conversationID: ConversationID, using editor: some GroupChatEditing) async throws -> Conversation { + guard picture != nil else { + throw EditGroupIncomplete() + } + + isSaving = true + defer { isSaving = false } + + let blobID = try await uploadPicture(using: editor) + + return try await editor.editChat( + conversationID: conversationID, + title: nil, + description: .unchanged, + pictureBlobID: nil, + coverPictureBlobID: blobID + ) + } + /// Runs the `EditChat` call and reports what it returned as a `field` edit. private static func tracked( _ field: GroupField, diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupPictureScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupPictureScreen.swift index 8c2b09aec..538dd1e23 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupPictureScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupPictureScreen.swift @@ -156,62 +156,9 @@ struct EditGroupPictureScreen: View { } } - /// Mirrors ``NewPublicGroupScreen``'s mapping so the same server answer reads the same way - /// wherever a group picture is submitted. private func handle(_ error: Error) { guard !Task.isCancelled else { return } - - switch error { - case ErrorEditChat.pictureBlobNotAccepted: - logger.info("Group picture not accepted") - ErrorReporting.captureError(error, reason: "Group picture not accepted") - dialog = .error( - title: "This Photo Isn't Allowed", - subtitle: "Try a different photo" - ) - - case ErrorEditChat.coverPictureBlobNotAccepted: - logger.info("Group cover picture not accepted") - ErrorReporting.captureError(error, reason: "Group cover picture not accepted") - dialog = .error( - title: "This Photo Isn't Allowed", - subtitle: "Try a different photo" - ) - - case ErrorEditChat.denied: - logger.info("Group edit denied") - ErrorReporting.captureError(error, reason: "Group edit denied") - dialog = .error( - title: "You Can't Edit This Group", - subtitle: "Try again later" - ) - - case ErrorEditChat.notFound: - logger.info("Group edit target not found") - ErrorReporting.captureError(error, reason: "Group edit target not found") - dialog = .error( - title: "This Group No Longer Exists", - subtitle: "It may have been deleted" - ) - - case let blobError as ErrorBlob: - logger.info("Group picture upload failed", metadata: ["error": "\(blobError)"]) - ErrorReporting.captureError(blobError, reason: "Group picture upload failed", userFacing: true) - dialog = .profilePictureFailed(blobError) - - case let encoderError as ImageEncoderError: - logger.error("Failed to encode the group picture", metadata: ["error": "\(encoderError)"]) - ErrorReporting.captureError(encoderError, reason: "Failed to encode the group picture") - dialog = .imageProcessingFailed - - default: - logger.error("Failed to edit group picture", metadata: ["error": "\(error)"]) - ErrorReporting.captureError(error, reason: "Failed to edit group picture") - dialog = .error( - title: "Couldn't Save This Photo", - subtitle: "Try again" - ) - } + dialog = .groupImageEditFailed(error, image: .picture) } private func handleFileImport(_ result: Result<[URL], Error>) { diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift index 47ff48565..dcb8cf48a 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift @@ -7,49 +7,49 @@ import SwiftUI import FlipcashCore import FlipcashUI -/// What a group's editor can change, one row each (node 10187:110373). +/// A group's editor, in Edit Profile's shape: the cover and photo at the top, then a card per field, +/// each opening its own screen that saves on its own. /// -/// The design draws four rows — Icon, Membership Card, Description and Social Links — but only the -/// first has contract support: `EditChatRequest` carries a title and a picture and nothing else, so -/// the other three would be rows that cannot save. Name is the counterpart the design omits, styled -/// to match. The design's "Icon" is called Picture here, matching both the contract's -/// `EditChatRequest.picture` and the row Android ships. +/// Balance Requirements is read-only: `EditChatRequest` carries no rules, and nothing changes a +/// group's rules after `StartChat`. struct EditGroupScreen: View { let conversationID: ConversationID @Environment(ConversationController.self) private var conversationController + @Environment(SessionContainer.self) private var sessionContainer @Environment(AppRouter.self) private var router + @State private var mintNames: [PublicKey: String] = [:] + + private static let coverHeight: CGFloat = 126 + private static let avatarSize: CGFloat = 68 + private static let cardSpacing: CGFloat = 12 + private var conversation: Conversation? { conversationController.conversation(withID: conversationID) } + private var requirements: GroupBalanceRequirements? { + GroupBalanceRequirements(conversation?.rules) + } + var body: some View { Background(color: .backgroundMain) { - VStack(spacing: 0) { - Row(insets: rowInsets, accessory: .chevron) { - Image.system(.photo) - .frame(minWidth: 45) - Text("Picture") - } action: { - router.push(.editGroupPicture(conversationID)) - } - .accessibilityIdentifier("edit-group-picture") - - Row(insets: rowInsets, accessory: .chevron) { - Image.system(.textformat) - .frame(minWidth: 45) - Text("Name") - } action: { - router.push(.editGroupName(conversationID)) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + cover + photo + fieldCards + .padding(.top, 20) + if let requirements { + balanceRequirements(requirements) + .padding(.top, 28) + } } - .accessibilityIdentifier("edit-group-name") - - Spacer() + .padding(.horizontal, ProfileHeaderMetrics.inset) + .padding(.vertical, 24) } - .font(.appDisplayXS) - .padding(.horizontal, 20) } .navigationTitle("Edit Group") .toolbarTitleDisplayMode(.inline) @@ -59,12 +59,143 @@ struct EditGroupScreen: View { .onChange(of: conversation?.canEdit ?? false) { _, canEdit in if !canEdit { router.popTopmost() } } + .task(id: conversation?.picture?.thumbnailBlobID) { + await sessionContainer.profileAvatars.load(.chat(conversationID), picture: conversation?.picture) + } + // The mint may be one the user holds nothing of, so the local store can miss and the fetch + // is what fills it. + .task(id: requirements?.mints) { + let session = sessionContainer.session + var names: [PublicKey: String] = [:] + for mint in requirements?.mints ?? [] { + if let stored = session.storedMintMetadata(for: mint) { + names[mint] = stored.name + } else if let fetched = try? await session.fetchMintMetadata(mint: mint).name { + names[mint] = fetched + } + } + mintNames = names + } + } + + // MARK: - Cover & photo - + + private var cover: some View { + Button { + router.push(.editGroupCover(conversationID)) + } label: { + ProfileCoverBanner(cover: .group(conversationID, picture: conversation?.coverPicture), bannerHeight: Self.coverHeight) + .clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-group-cover") + .overlay(alignment: .topTrailing) { + Button("Change cover") { + router.push(.editGroupCover(conversationID)) + } + .buttonStyle(.plain) + .modifier(CoverChip()) + .padding(Self.cardSpacing) + .accessibilityIdentifier("edit-group-change-cover") + } + } + + /// The group picture overlapping the cover's bottom edge, with a camera badge and a caption beside it. + private var photo: some View { + Button { + router.push(.editGroupPicture(conversationID)) + } label: { + HStack(alignment: .bottom, spacing: Self.cardSpacing) { + ContactAvatarView( + id: conversationID.description, + displayName: conversation?.title ?? "", + imageData: sessionContainer.profileAvatars.data(for: .chat(conversationID)), + blurhash: conversation?.picture?.thumbnailBlurhash, + size: Self.avatarSize + ) + .overlay { Circle().strokeBorder(Color.backgroundMain, lineWidth: 5) } + .overlay(alignment: .bottomTrailing) { cameraBadge } + + Text("Change photo") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .padding(.bottom, 6) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("edit-group-picture") + .padding(.top, -Self.avatarSize / 2) + .padding(.leading, 16) + } + + private var cameraBadge: some View { + Image.asset(.camera) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .frame(width: 28, height: 28) + .background(Circle().fill(Color.action.opacity(0.1))) + .background(Circle().fill(Color.backgroundMain)) + .offset(x: 6, y: 2) + } + + // MARK: - Fields - + + private var fieldCards: some View { + VStack(spacing: Self.cardSpacing) { + FieldCard(title: "Group name", value: nonEmpty(conversation?.title), placeholder: "Add a name") { + router.push(.editGroupName(conversationID)) + } + .accessibilityIdentifier("edit-group-name") + + FieldCard(title: "Description", value: nonEmpty(conversation?.description), placeholder: "Add a description", lineLimit: 3) { + router.push(.editGroupDescription(conversationID)) + } + .accessibilityIdentifier("edit-group-description") + } + } + + // MARK: - Balance requirements - + + private func balanceRequirements(_ requirements: GroupBalanceRequirements) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Balance Requirements") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + + VStack(spacing: 0) { + requirementRow("Join", requirements.join) + Divider() + .overlay(Color.rowSeparator) + requirementRow("Chat", requirements.chat) + } + .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) + + Text("People keep their balance. These amounts determine who can join and send messages.") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .accessibilityIdentifier("edit-group-balance-requirements") + } + + private func requirementRow(_ title: String, _ requirement: MinimumBalanceRequirement?) -> some View { + HStack { + Text(title) + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + Spacer() + Text(requirement.map { GroupBalanceRequirements.formatted($0, mintName: $0.mints.first.flatMap { mintNames[$0] }) } ?? "None") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .accessibilityElement(children: .combine) } - /// The 25pt vertical rhythm every other list in the app uses, near enough to the design's ~24pt - /// that matching the app is the better trade. Leading inset is zero because the row's own icon - /// column starts the content; the 20pt screen padding holds it off the edge. - private var rowInsets: EdgeInsets { - .init(top: 25, leading: 0, bottom: 25, trailing: 0) + private func nonEmpty(_ string: String?) -> String? { + guard let string, !string.isEmpty else { return nil } + return string } } diff --git a/Flipcash/Core/Screens/Profile/Edit Group/GroupBalanceRequirements.swift b/Flipcash/Core/Screens/Profile/Edit Group/GroupBalanceRequirements.swift new file mode 100644 index 000000000..f12bc0282 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/GroupBalanceRequirements.swift @@ -0,0 +1,23 @@ +// +// GroupBalanceRequirements.swift +// Flipcash +// + +import FlipcashCore + +extension GroupBalanceRequirements { + + /// The mints the card has to name, without duplicates. + var mints: Set { + Set([join, chat].compactMap { $0?.mints.first }) + } + + /// A requirement's amount, worded as the gate panel words it: the dollar amount, plus the + /// token's name unless it is the dollar token. + static func formatted(_ requirement: MinimumBalanceRequirement, mintName: String?) -> String { + let amount = requirement.amount.formattedDroppingZeroFraction() + let mint = requirement.mints.first + guard mint != .usdf, let mintName else { return amount } + return "\(amount) of \(mintName)" + } +} diff --git a/FlipcashTests/Chat/EditGroupDescriptionModelTests.swift b/FlipcashTests/Chat/EditGroupDescriptionModelTests.swift new file mode 100644 index 000000000..32a088816 --- /dev/null +++ b/FlipcashTests/Chat/EditGroupDescriptionModelTests.swift @@ -0,0 +1,116 @@ +// +// EditGroupDescriptionModelTests.swift +// FlipcashTests +// + +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Group description editor") +struct EditGroupDescriptionModelTests { + + private struct Boom: Error {} + + @MainActor + private final class Calls { + var edits: [ConversationDescriptionEdit] = [] + } + + private func makeModel( + description: String = "Bad boys only", + calls: Calls = Calls(), + saving: ((ConversationDescriptionEdit) async throws -> Void)? = nil + ) -> EditGroupDescriptionModel { + EditGroupDescriptionModel(description: description) { edit in + calls.edits.append(edit) + try await saving?(edit) + } + } + + @Test("The text starts as the current description and the counter counts down from 160") + func initialState() { + let model = makeModel(description: "Hello") + + #expect(model.text == "Hello") + #expect(model.remaining == 155) + } + + @Test("Save is shut while unchanged or over the limit, open when cleared") + func canSave() { + let model = makeModel(description: "Hello") + #expect(!model.canSave) + + model.text = "Hello there" + #expect(model.canSave) + + model.text = String(repeating: "a", count: 161) + #expect(!model.canSave) + + model.text = "" + #expect(model.canSave) + } + + @Test("A changed description is sent trimmed as a set, and the save lands") + func savesTrimmedDescription() async { + let calls = Calls() + let model = makeModel(calls: calls) + model.text = " Good boys only \n" + + await model.save() + + #expect(calls.edits == [.set("Good boys only")]) + #expect(model.state == .saved) + #expect(model.fieldError == nil) + #expect(model.failure == nil) + } + + @Test("An emptied description is sent as a clear, not as an empty set") + func emptyDescriptionClears() async { + let calls = Calls() + let model = makeModel(calls: calls) + model.text = " " + + await model.save() + + #expect(calls.edits == [.clear]) + #expect(model.state == .saved) + } + + @Test("A moderation rejection lands under the field and reopens Save") + func moderationRejectionShowsInline() async { + let model = makeModel { _ in throw ErrorEditChat.descriptionModerated(.init()) } + model.text = "Something else" + + await model.save() + + #expect(model.fieldError == .moderated) + #expect(model.failure == nil) + #expect(model.state == .normal) + #expect(model.canSave) + } + + @Test("Editing the text clears a moderation error") + func editingClearsFieldError() async { + let model = makeModel { _ in throw ErrorEditChat.descriptionModerated(.init()) } + model.text = "Something else" + await model.save() + + model.text = "Something kinder" + + #expect(model.fieldError == nil) + } + + @Test("Any other failure surfaces as a dialog, not under the field") + func otherFailureSurfacesAsDialog() async { + let model = makeModel { _ in throw Boom() } + model.text = "Something else" + + await model.save() + + #expect(model.failure is Boom) + #expect(model.fieldError == nil) + #expect(model.state == .normal) + } +} diff --git a/FlipcashTests/Chat/EditGroupModelTests.swift b/FlipcashTests/Chat/EditGroupModelTests.swift index 089bbcfe2..c75a6f831 100644 --- a/FlipcashTests/Chat/EditGroupModelTests.swift +++ b/FlipcashTests/Chat/EditGroupModelTests.swift @@ -254,6 +254,67 @@ struct EditGroupModelTests { #expect(editor.storeBlobCallCount == 2) } + + // MARK: - Cover - + + @Test("A cover edit sends the blob as the cover alone, leaving the title and picture unchanged") + func coverEditSendsOnlyTheCover() async throws { + let model = EditGroupModel(title: "BadBoys") + model.select(picture: swatch()) + let editor = SpyEditor() + + _ = try await model.saveCover(for: conversationID, using: editor) + + #expect(editor.editCalls.count == 1) + #expect(editor.editCalls[0].coverBlobID != nil) + #expect(editor.editCalls[0].blobID == nil) + #expect(editor.editCalls[0].title == nil) + #expect(editor.editCalls[0].description == .unchanged) + #expect(editor.callOrder == ["store", "finalize", "edit"]) + } + + @Test("A cover the moderator refuses is never sent, and the next save uploads fresh bytes") + func moderatedCoverNeverReachesTheEdit() async throws { + let model = EditGroupModel() + model.select(picture: swatch()) + let editor = SpyEditor() + editor.nextFinalizationError = ErrorBlob.rejected(.moderation) + + await #expect(throws: ErrorBlob.self) { + try await model.saveCover(for: conversationID, using: editor) + } + #expect(editor.editCalls.isEmpty) + + _ = try await model.saveCover(for: conversationID, using: editor) + + #expect(editor.storeBlobCallCount == 2) + #expect(editor.editCalls.count == 1) + } + + @Test("A cover the edit itself refuses surfaces as that error") + func refusedCoverSurfaces() async { + let model = EditGroupModel() + model.select(picture: swatch()) + let editor = SpyEditor() + editor.nextEditError = ErrorEditChat.coverPictureBlobNotAccepted + + await #expect(throws: ErrorEditChat.coverPictureBlobNotAccepted) { + try await model.saveCover(for: conversationID, using: editor) + } + } + + @Test("A cover save with nothing picked sends nothing") + func coverWithoutPictureSendsNothing() async { + let model = EditGroupModel() + let editor = SpyEditor() + + await #expect(throws: EditGroupIncomplete.self) { + try await model.saveCover(for: conversationID, using: editor) + } + + #expect(editor.storeBlobCallCount == 0) + #expect(editor.editCalls.isEmpty) + } } @MainActor diff --git a/FlipcashTests/Chat/GroupChangeDialogTests.swift b/FlipcashTests/Chat/GroupChangeDialogTests.swift index 9727744fd..9a1201808 100644 --- a/FlipcashTests/Chat/GroupChangeDialogTests.swift +++ b/FlipcashTests/Chat/GroupChangeDialogTests.swift @@ -15,6 +15,8 @@ struct GroupChangeDialogTests { @Test("Names the field in the title and the confirming button", arguments: [ (DialogItem.GroupField.name, "Group Name"), (.picture, "Group Picture"), + (.cover, "Group Cover"), + (.description, "Group Description"), ]) func namesField(field: DialogItem.GroupField, label: String) { let item = DialogItem.confirmGroupChange(field) {} @@ -28,6 +30,8 @@ struct GroupChangeDialogTests { @Test("Says the group's, not the user's own", arguments: [ DialogItem.GroupField.name, .picture, + .cover, + .description, ]) func qualifiesTheFieldAsTheGroups(field: DialogItem.GroupField) { let item = DialogItem.confirmGroupChange(field) {} @@ -39,6 +43,8 @@ struct GroupChangeDialogTests { @Test("States the change in the body", arguments: [ (DialogItem.GroupField.name, "This will change the group name for everyone in it"), (.picture, "This will change the group picture for everyone in it"), + (.cover, "This will change the group cover for everyone in it"), + (.description, "This will change the group description for everyone in it"), ]) func statesTheChange(field: DialogItem.GroupField, body: String) { let item = DialogItem.confirmGroupChange(field) {} @@ -49,6 +55,8 @@ struct GroupChangeDialogTests { @Test("Confirms over Cancel", arguments: [ DialogItem.GroupField.name, .picture, + .cover, + .description, ]) func actions(field: DialogItem.GroupField) { let item = DialogItem.confirmGroupChange(field) {} @@ -60,6 +68,8 @@ struct GroupChangeDialogTests { @Test("Runs the caller's work only once the change is confirmed", arguments: [ DialogItem.GroupField.name, .picture, + .cover, + .description, ]) func confirmAction_runsHandler(field: DialogItem.GroupField) { var confirmed = false @@ -73,6 +83,8 @@ struct GroupChangeDialogTests { @Test("Cancel leaves the caller's work unrun", arguments: [ DialogItem.GroupField.name, .picture, + .cover, + .description, ]) func cancelAction_doesNotRunHandler(field: DialogItem.GroupField) { var confirmed = false @@ -84,11 +96,13 @@ struct GroupChangeDialogTests { /// Both edits land on everyone in the group at once, so both carry the alert's weight rather /// than the grey banner the reversible profile fields use. - @Test("Red banner for both", arguments: [ + @Test("Red banner for every field", arguments: [ DialogItem.GroupField.name, .picture, + .cover, + .description, ]) - func bothAreDestructive(field: DialogItem.GroupField) { + func everyFieldIsDestructive(field: DialogItem.GroupField) { let item = DialogItem.confirmGroupChange(field) {} #expect(item.style == .destructive) @@ -98,6 +112,8 @@ struct GroupChangeDialogTests { @Test("Not an error worth reporting", arguments: [ DialogItem.GroupField.name, .picture, + .cover, + .description, ]) func untracked(field: DialogItem.GroupField) { #expect(DialogItem.confirmGroupChange(field) {}.tracked == false)