diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index fd264529f..f84dc3a3e 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 9A017D692EAC1DE200216925 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 9A017D682EAC1DE200216925 /* Kingfisher */; }; 9A444EE72E8C414D002B1E39 /* BigDecimal in Frameworks */ = {isa = PBXBuildFile; productRef = 9A444EE62E8C414D002B1E39 /* BigDecimal */; }; 9A780F972EDF817D009BC4D9 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 9A780F962EDF817D009BC4D9 /* CodeScanner */; }; - 9AE5C0122FA10003004C0DE0 /* SharedCoreKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9AE5C0112FA10002004C0DE0 /* SharedCoreKit */; }; 9ABDD1952D9D7B61006B6CDA /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */; }; 9AC011182DA4320F0030298E /* FlipcashUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9AC011172DA4320F0030298E /* FlipcashUI */; }; 9AC0156C2DA576FA0030298E /* opencv2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AD5E64B25F2BF3E007388AE /* opencv2.framework */; }; @@ -181,7 +180,6 @@ 9ABDD1952D9D7B61006B6CDA /* FlipcashCore in Frameworks */, 4CA1B0032FD00003000AA003 /* FlipcashStore in Frameworks */, 9ADEF1D92DD627C6001B260A /* Mixpanel in Frameworks */, - 9AE5C0122FA10003004C0DE0 /* SharedCoreKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -387,7 +385,6 @@ 9A780F962EDF817D009BC4D9 /* CodeScanner */, 4CB225752F77260D0075874E /* FirebaseInstallations */, 4CB225772F7726500075874E /* FirebaseMessaging */, - 9AE5C0112FA10002004C0DE0 /* SharedCoreKit */, ); productName = Flipcash; productReference = 9A516F7A2D9B12E900AF478B /* Flipcash.app */; @@ -516,7 +513,6 @@ 10A6C0E8F8974C1887219D41 /* XCRemoteSwiftPackageReference "tweetnacl-swiftwrap" */, 9A444EE52E8C414D002B1E39 /* XCRemoteSwiftPackageReference "BigDecimal" */, 9A780F952EDF817D009BC4D9 /* XCLocalSwiftPackageReference "CodeScanner" */, - 9AE5C0102FA10001004C0DE0 /* XCRemoteSwiftPackageReference "flipcash-shared-core-spm" */, ); productRefGroup = 9AED1101258BE1310088D902 /* Products */; projectDirPath = ""; @@ -1770,14 +1766,6 @@ kind = branch; }; }; - 9AE5C0102FA10001004C0DE0 /* XCRemoteSwiftPackageReference "flipcash-shared-core-spm" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/code-payments/flipcash-shared-core-spm"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.7.0; - }; - }; 9ACE172927E6287C00ACA047 /* XCRemoteSwiftPackageReference "mixpanel-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mixpanel/mixpanel-swift"; @@ -1839,11 +1827,6 @@ package = 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite" */; productName = SQLite; }; - 9AE5C0112FA10002004C0DE0 /* SharedCoreKit */ = { - isa = XCSwiftPackageProductDependency; - package = 9AE5C0102FA10001004C0DE0 /* XCRemoteSwiftPackageReference "flipcash-shared-core-spm" */; - productName = SharedCoreKit; - }; 9ADEF1D62DD627C0001B260A /* Bugsnag */ = { isa = XCSwiftPackageProductDependency; package = 9A476D482978884700C5B5CE /* XCRemoteSwiftPackageReference "bugsnag-cocoa" */; diff --git a/CrossPlatformVectors/Package.swift b/CrossPlatformVectors/Package.swift index 8932fdebf..c2c6a1fa7 100644 --- a/CrossPlatformVectors/Package.swift +++ b/CrossPlatformVectors/Package.swift @@ -1,5 +1,25 @@ // swift-tools-version: 5.9 import PackageDescription +import Foundation + +// `FLIPCASH_SHARED_CORE_LOCAL` swaps the pinned package for a path dependency into a +// `code-android-app` checkout. `FlipcashCore/Package.swift` carries the full rationale and the +// loop; the one thing to know here is that all three manifests consuming `SharedCoreKit` have to +// move together, because SPM resolves one version for the whole graph. +let sharedCoreLocalRoot = ProcessInfo.processInfo.environment["FLIPCASH_SHARED_CORE_LOCAL"] + .map { ($0 as NSString).expandingTildeInPath } + .flatMap { $0.isEmpty ? nil : $0 } + +// A path dependency takes its identity from the directory's basename rather than the `Package(name:)` +// inside it, so under the override the package is `spm`. Product references have to follow. +let sharedCorePackage = sharedCoreLocalRoot == nil ? "flipcash-shared-core-spm" : "spm" + +let sharedCore: Package.Dependency = { + if let sharedCoreLocalRoot { + return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") + } + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")) +}() // Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs // with reproduces the canonical fixtures in `code/test-vectors/` — the SAME fixtures the Android @@ -21,12 +41,12 @@ let package = Package( .iOS(.v15), ], dependencies: [ - .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")), + sharedCore, ], targets: [ .testTarget( name: "CrossPlatformVectorsTests", - dependencies: [.product(name: "SharedCoreKit", package: "flipcash-shared-core-spm")], + dependencies: [.product(name: "SharedCoreKit", package: sharedCorePackage)], resources: [.copy("Fixtures")] ), ] diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 93947a280..fe80e0994 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1149,9 +1149,11 @@ final class ConversationController { case .conflict: "Message Changed" case .failure: switch action { - case .edit: "Couldn't Edit Message" - case .delete: "Couldn't Delete Message" - case .copy, .reply: "Something Went Wrong" + case .edit: "Couldn't Edit Message" + case .delete: "Couldn't Delete Message" + // Neither of these mutates the transcript, so neither can raise a mutation alert. + // Report files straight from its sheet and reports its own outcome there. + case .copy, .reply, .report: "Something Went Wrong" } } } diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index fc90857f4..2033adce1 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -300,8 +300,11 @@ extension ChatItem { /// Menu order is fixed here, not at the call site — a `Set` has no order, and the context menu /// must not shuffle its rows between renders of the same message. + /// + /// Report goes last: it is the rarest row and the only one that never appears on your own + /// message, so the menu reads "yours ends with delete, theirs ends with report". nonisolated private static func orderedActions(_ capabilities: Set) -> [MessageCapability] { - [.copy, .reply, .edit, .delete].filter(capabilities.contains) + [.copy, .reply, .edit, .delete, .report].filter(capabilities.contains) } /// "Read 3:42 PM" / "Read Yesterday" / "Read Monday" / "Read Tue, Jun 17" once the counterpart's diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index f435ab322..5080693d4 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -189,8 +189,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen?.focusComposer() case .reply: screen?.focusComposer() - case .delete: screen?.dismissKeyboard() - case .copy: break + // Both raise a sheet over the transcript, so the keyboard goes down first. + case .delete, .report: screen?.dismissKeyboard() + case .copy: break } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 52b470588..996941c45 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -83,6 +83,7 @@ struct ConversationScreen: View { /// Whether the invite sheet is up. A link is the only way into a group, so the empty group's /// card hands one out (node 10127:118280). @State private var isInviting = false + @State private var messageReport: MessageReportRequest? /// Horizontal space the back button (leading) reserves on each side of the /// centered title item, so the avatar + name can left-align inside a @@ -647,6 +648,13 @@ struct ConversationScreen: View { GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) } } + .fullScreenCover(item: $messageReport) { report in + NavigationStack { + ReportFlowScreen( + target: .message(chatID: report.chatID, messageID: report.messageID) + ) + } + } .background { // Measure the bar width so the centered title item can be sized to // (almost) fill it — the system toolbar won't honor maxWidth on a @@ -835,9 +843,24 @@ struct ConversationScreen: View { composer.beginEditing(messageID: message.id, stableID: stableID, currentText: text) case .delete: confirmDelete(message.id) + case .report: + // A report names the message, not the chat and not the writer: a chat-level report from + // here would name a DM id this client derived, and the server has never been told about + // that one. + guard let conversationID else { return } + messageReport = MessageReportRequest(id: stableID, chatID: conversationID, messageID: message.id) } } + /// The message a report is being filed against, so the sheet can be presented by item rather + /// than from a loose boolean and a second piece of state that could disagree with it. + private struct MessageReportRequest: Identifiable { + /// The transcript row's stable id — already unique per row, and already a `String`. + let id: String + let chatID: ConversationID + let messageID: MessageID + } + /// The composer strip's preview of the message being answered — the same three-way split the /// transcript's quote panel uses, so the strip and the sent bubble read alike. /// diff --git a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift new file mode 100644 index 000000000..253899773 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift @@ -0,0 +1,328 @@ +// +// ReportFlowScreen.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Reporting a person, a group, or a message. +/// +/// Two steps, and most reports only see the first: picking a reason and confirming it files the +/// report. ``ReportReason/other`` is the exception, because only it has nothing to go on without a +/// second screen. +/// +/// Steps rather than one sheet that swaps its own contents. A sheet sized to its content is the +/// wrong shape for a step that grows a text field and a keyboard, and the choice also buys a real +/// back: returning to the reasons keeps both the pick and the words, because leaving the details by +/// the back arrow is usually a second thought about the reason rather than about what happened. +/// +/// What goes on the wire is assembled by ``ReportDescription/build(reason:details:)`` in shared +/// code, so this screen and Android's produce the same `description` for the same choice. Nothing +/// here formats anything. +struct ReportFlowScreen: View { + + let target: ReportTarget + + @State private var state = ReportFlowState() + @State private var dialog: DialogItem? + @State private var direction: Direction = .forward + + @Environment(\.dismiss) private var dismiss + @Environment(SessionContainer.self) private var sessionContainer + + private enum Direction { + case forward, backward + + var slide: AnyTransition { + switch self { + case .forward: + .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)) + case .backward: + .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)) + } + } + } + + var body: some View { + Background(color: .backgroundMain) { + ZStack { + switch state.step { + case .reason: + ReasonStep(state: state, onContinue: advance) + .transition(direction.slide) + case .details: + DetailsStep(state: state, onSubmit: submitDetails) + .transition(direction.slide) + } + } + } + .navigationTitle("Report") + .toolbarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + // A half-written report is not something to lose to a stray downward drag. + .interactiveDismissDisabled() + .toolbar { + // One control per step, in the corner iOS puts it: dismissal trailing, back leading. + // Past the reasons the only way out is back through them, so the details a person has + // typed cannot be dropped by a control sitting next to the keyboard. + switch state.step { + case .reason: + ToolbarItem(placement: .topBarTrailing) { + CloseButton { dismiss() } + .accessibilityIdentifier("report-nav-close") + } + + case .details: + ToolbarItem(placement: .topBarLeading) { + Button(action: goBackToReasons) { + Image(systemName: "chevron.backward") + .foregroundStyle(Color.textMain) + } + .accessibilityIdentifier("report-nav-back") + .accessibilityLabel("Back") + } + } + } + .dialog(item: $dialog) + } + + private func goBackToReasons() { + direction = .backward + withAnimation { state.step = .reason } + } + + /// The button at the bottom of the reasons: a second, deliberate press on a pick already made. + /// + /// Tapping a row only selects it. Filing a report cannot be undone from here and the labels sit + /// close enough together that a mis-tap is a plausible way to reach the wrong one, so the extra + /// press buys back a whole class of mistake for one tap. + private func advance() { + guard let reason = state.selectedReason else { return } + if ReportFlowState.needsDetails(reason) { + direction = .forward + withAnimation { state.step = .details } + } else { + Task { await submit(reason, details: nil) } + } + } + + private func submitDetails() { + guard let reason = state.selectedReason else { return } + Task { await submit(reason, details: state.details) } + } + + /// Files the report, then waits to be acknowledged. + /// + /// What closes this flow is the confirmation being dismissed, not the send returning. A report + /// answers inside a frame, so a flow that vanished on the call would leave its confirmation to + /// land on whatever happened to be behind it — and say nothing at all when the send had failed. + /// + /// The contract makes a duplicate report a no-op that answers OK, so the acknowledgement reads + /// the same whether or not this was the first one. Telling someone they had already reported + /// this would be answering a question they did not ask. + private func submit(_ reason: ReportReason, details: String?) async { + state.buttonState = .loading + + // A floor under the spinner, run alongside the send rather than before it: the call + // answers inside a frame, so without one the spinner never renders, and with a blocking + // delay a slow send would be held back by its own progress indicator. + let floor = Task { try? await Task.sleep(for: .milliseconds(400)) } + + do { + try await sessionContainer.flipClient.report( + owner: sessionContainer.session.ownerKeyPair, + target: target, + description: ReportDescription.build(reason: reason, details: details) + ) + await floor.value + state.buttonState = .success + // The checkmark is drawn on the frame after the state flips, so raising the dialog + // immediately would drop a scrim over one nobody saw. + try? await Task.sleep(for: .milliseconds(500)) + dialog = DialogItem.success( + title: "Report Sent", + subtitle: "Thanks. We'll take a look" + ).onDismiss { dismiss() } + } catch { + floor.cancel() + // Left where it was, with the reason still picked: a retry is one press. + state.buttonState = .normal + dialog = .error( + title: "Something Went Wrong", + subtitle: "We were unable to send your report. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to file report") + } + } +} + +// MARK: - ReasonStep + +private struct ReasonStep: View { + + @Bindable var state: ReportFlowState + let onContinue: () -> Void + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + Text("Tell us what's wrong. Your report is private — we won't tell them who reported") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 20) + + VStack(spacing: 8) { + ForEach(ReportReason.displayOrder, id: \.self) { reason in + ReportReasonRow( + reason: reason, + isSelected: state.selectedReason == reason + ) { + state.selectedReason = reason + } + } + } + .padding(.top, 24) + } + } + .scrollIndicators(.hidden) + + CodeButton( + state: state.buttonState, + style: .filled, + title: ReportFlowState.buttonTitle(for: state.selectedReason), + disabled: state.selectedReason == nil || state.buttonState != .normal, + action: onContinue + ) + .accessibilityIdentifier("report-reason-continue") + .padding(.top, 12) + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + } +} + +// MARK: - ReportReasonRow + +/// One choice: an indicator, its label, and the line of scope under it. +/// +/// The whole row is the target and the indicator only reports state, so there is no second, +/// smaller thing to hit inside it. +private struct ReportReasonRow: View { + + let reason: ReportReason + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") + .font(.system(size: 20)) + .foregroundStyle(isSelected ? Color.textMain : Color.textSecondary) + + VStack(alignment: .leading, spacing: 4) { + Text(reason.title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + + Text(reason.summary) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow, in: .rect(cornerRadius: 12)) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityIdentifier("report-reason-\(reason.identifier)") + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } +} + +// MARK: - DetailsStep + +private struct DetailsStep: View { + + @Bindable var state: ReportFlowState + let onSubmit: () -> Void + + @FocusState private var isFocused: Bool + + /// Counts down rather than up, matching the currency wizard's fields. + private var remaining: Int { + ReportDescription.maxDetailsLength - state.details.count + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + // Two blocks rather than one sentence: the ask is what the screen wants, the + // privacy line is what it offers in return. Run together at one weight they + // compete, and the part that reassures is the part that gets skipped. + Text("Tell us what's wrong") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + + Text("Your report is private — we won't tell them who reported") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + TextField("Tell us more", text: $state.details, axis: .vertical) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .focused($isFocused) + .padding(.top, 16) + .accessibilityIdentifier("report-details") + .onChange(of: state.details) { _, new in + // Clipped rather than refused: the cap is the contract's, not a rule + // anyone broke, and the counter above has been saying so all along. + if new.count > ReportDescription.maxDetailsLength { + state.details = String(new.prefix(ReportDescription.maxDetailsLength)) + } + } + + Color.clear.frame(height: 60) + } + .padding(.top, 20) + } + .scrollDismissesKeyboard(.interactively) + .scrollIndicators(.hidden) + + Text("\(remaining) characters") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("report-details-counter") + .padding(.bottom, 12) + + // Picking "Something Else" and then saying nothing leaves the report with less to go + // on than any other row would have carried, so the button waits. + CodeButton( + state: state.buttonState, + style: .filled, + title: "Submit Report", + disabled: !ReportFlowState.canSubmitDetails(state.details) || state.buttonState != .normal, + action: onSubmit + ) + .accessibilityIdentifier("report-submit") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .onAppear { isFocused = true } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ReportFlowState.swift b/Flipcash/Core/Screens/Conversation/ReportFlowState.swift new file mode 100644 index 000000000..b765c08d9 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ReportFlowState.swift @@ -0,0 +1,54 @@ +// +// ReportFlowState.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import FlipcashUI + +/// What has been chosen so far, and how far through the report we are. +/// +/// Held apart from ``ReportFlowScreen`` because stepping to the details and back must not lose the +/// pick that got you there, nor the words already typed — going back is usually a second thought +/// about the reason, not about what happened. A `@State` on the step's own view dies with the step. +@Observable +final class ReportFlowState { + + /// Picking a reason, and — for ``ReportReason/other`` alone — saying what happened. + enum Step: Equatable { + case reason + case details + } + + var step: Step = .reason + + /// Nil until a row is picked. The button waits on it. + var selectedReason: ReportReason? + + var details: String = "" + + /// Carries the send: the wait, then the checkmark, on whichever step submitted. + var buttonState: ButtonState = .normal + + /// Whether picking this reason needs a second step before there is a report worth filing. + /// + /// `nonisolated` and static so the branch is reachable from a test without standing up a view. + nonisolated static func needsDetails(_ reason: ReportReason) -> Bool { + reason == .other + } + + /// What the button will do, spelled on the button, because it differs by row. + nonisolated static func buttonTitle(for reason: ReportReason?) -> String { + reason.map { needsDetails($0) ? "Next" : "Submit Report" } ?? "Submit Report" + } + + /// Whitespace alone is not an answer, and this is the same trim + /// ``ReportDescription/build(reason:details:)`` applies — so the button and the wire agree on + /// what "blank" means. + nonisolated static func canSubmitDetails(_ details: String) -> Bool { + !details.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Flipcash/Core/Screens/Conversation/ReportReason+Presentation.swift b/Flipcash/Core/Screens/Conversation/ReportReason+Presentation.swift new file mode 100644 index 000000000..b3a61da25 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ReportReason+Presentation.swift @@ -0,0 +1,81 @@ +// +// ReportReason+Presentation.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// The words each reason wears, and the slug it answers to in a test. +/// +/// Kept here rather than on the shared enum: ``ReportReason`` is compiled for both platforms, and +/// each app writes its own copy against it. What must not differ is the token +/// ``ReportDescription/build(reason:details:)`` puts on the wire, and that never comes from here. +/// +/// A table rather than a `switch`, because a Kotlin enum arrives in Swift as a class and the +/// compiler cannot check a `switch` over one for exhaustiveness. `ReportReasonPresentationTests` +/// does that check instead, walking ``ReportReason/displayOrder``, so a reason added upstream fails +/// a test here instead of shipping a row with no words on it. +extension ReportReason { + + /// The row's label. + var title: String { + Self.presentation[self]?.title ?? "" + } + + /// One line of scope under the label. + /// + /// Six words of label leave people guessing which bucket their situation belongs in, and a + /// guess resolves as the nearest wrong one — which costs both the reporter and whoever reads + /// it. These say what the row collects, and promise nothing about what happens next. + var summary: String { + Self.presentation[self]?.summary ?? "" + } + + /// Stable slug for accessibility identifiers. Not the wire token — that is the shared + /// builder's to decide. + var identifier: String { + Self.presentation[self]?.identifier ?? "" + } + + private struct Presentation { + let title: String + let summary: String + let identifier: String + } + + private static let presentation: [ReportReason: Presentation] = [ + .spam: Presentation( + title: "Spam", + summary: "Repeated unwanted messages, promotions, or links", + identifier: "spam" + ), + .scamorfraud: Presentation( + title: "Scam or Fraud", + summary: "Attempts to take money or account details by deception", + identifier: "scam-or-fraud" + ), + .harassment: Presentation( + title: "Harassment or Bullying", + summary: "Abuse, insults, or repeated unwanted contact", + identifier: "harassment" + ), + .sexualcontent: Presentation( + title: "Sexual Content", + summary: "Explicit content, or unwanted sexual messages", + identifier: "sexual-content" + ), + .violence: Presentation( + title: "Violence or Threats", + summary: "Threats of harm, or content showing violence", + identifier: "violence" + ), + .other: Presentation( + title: "Something Else", + summary: "None of these fit — tell us in your own words", + identifier: "other" + ), + ] +} diff --git a/Flipcash/Core/Screens/Main/You/TipCardExport.swift b/Flipcash/Core/Screens/Main/You/TipCardExport.swift index 9cb897b2b..84a52dcd2 100644 --- a/Flipcash/Core/Screens/Main/You/TipCardExport.swift +++ b/Flipcash/Core/Screens/Main/You/TipCardExport.swift @@ -6,7 +6,6 @@ import SwiftUI import UIKit import FlipcashUI -import SharedCoreKit /// Writes a tip code out as a file for the You tab's "Download As" sheet. /// @@ -73,7 +72,7 @@ enum TipCardExport { // MARK: - Rendering - private static func svg(codeData: Data) -> String { - KikCode.svg(payload: codeData, dimension: dimension, background: backgroundHex) + CodeView.svg(data: codeData, dimension: dimension, background: backgroundHex) } @MainActor diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 78248ce89..7859281f3 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -79,8 +79,8 @@ struct ChatProfileScreen: View { ChatMuteStatusLabel(conversationID: conversationID) } - if isMember { - VStack(spacing: 0) { + VStack(spacing: 0) { + if isMember { Row(insets: rowInsets, accessory: .chevron) { Image(systemName: "person.badge.plus") .frame(minWidth: 45) @@ -92,7 +92,15 @@ struct ChatProfileScreen: View { .accessibilityIdentifier("chat-profile-invite") ChatMuteRow(conversationID: conversationID, insets: rowInsets) + } + + // Outside the membership check, unlike every other row here. A non-member has + // no link to hand out, nothing to leave, and no viewer state on a chat they are + // not in — but a group you have already left is the one you are most likely to + // report. + ReportRow(target: .chat(conversationID), insets: rowInsets) + if isMember { Row( insets: rowInsets, disabled: isLeaving, @@ -107,9 +115,9 @@ struct ChatProfileScreen: View { } .accessibilityIdentifier("chat-profile-leave") } - .font(.appDisplayXS) - .padding(.top, 24) } + .font(.appDisplayXS) + .padding(.top, 24) Spacer() } diff --git a/Flipcash/Core/Screens/Profile/ReportRow.swift b/Flipcash/Core/Screens/Profile/ReportRow.swift new file mode 100644 index 000000000..c49c26527 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ReportRow.swift @@ -0,0 +1,52 @@ +// +// ReportRow.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The report control as both profile surfaces draw it: one row that opens the reason sheet. +/// +/// A named unit rather than a copy per screen, for the same reason ``ChatMuteRow`` is one — except +/// that here the two screens report *different things*, so the target is the parameter. A user's +/// profile names the person; a group's names the chat. Never the other way around: a tip DM's id is +/// derived on this client, so a report naming that chat would name something the server has never +/// been told about. +struct ReportRow: View { + + let target: ReportTarget + let insets: EdgeInsets + var chevron: ChatMuteRow.Chevron = .standard + + @State private var isReporting = false + + var body: some View { + Row(insets: insets, accessory: chevron == .standard ? .chevron : nil) { + Image(systemName: "flag") + .frame(minWidth: 45) + Text("Report") + .foregroundStyle(.textMain) + + if chevron == .secondary { + // Nothing has claimed the slack, so the chevron needs the spacer the accessory + // would have brought. + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.textSecondary) + } + } action: { + isReporting = true + } + .accessibilityIdentifier("chat-report") + .fullScreenCover(isPresented: $isReporting) { + NavigationStack { + ReportFlowScreen(target: target) + } + } + } +} diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index ab72b33bb..140d0ded8 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -92,12 +92,15 @@ private struct UserProfileContent: View { } VStack(spacing: 0) { - // Mute sits above Block the way it sits above Leave on a group: the reversible - // action first, the one that ends the relationship last. + // Mute, then report, then block: the reversible and routine first, then the + // one that asks someone else to look, then the one that ends the relationship. + // Same shape as a group's profile, where leaving holds the last place. if let conversationID { ChatMuteRow(conversationID: conversationID, insets: rowInsets, chevron: .secondary) } + ReportRow(target: .user(model.userID), insets: rowInsets, chevron: .secondary) + Row(insets: rowInsets) { Image(systemName: "nosign") .frame(minWidth: 45) @@ -111,6 +114,7 @@ private struct UserProfileContent: View { } action: { dialogItem = blockDialog() } + .accessibilityIdentifier("chat-block") } .font(.appDisplayXS) .padding(.top, 24) diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index 99291ce2e..42dc35040 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -2,6 +2,55 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +import Foundation + +// `FLIPCASH_SHARED_CORE_LOCAL` points at a `code-android-app` checkout and swaps the pinned +// package below for a path dependency into that checkout's `kmp/shared-core/spm`, so a change to +// `:kmp:shared-core` — including the Swift facade under `Sources/SharedCoreKit` — can be tried +// from an iOS build without publishing a version to consume it: +// +// cd code-android-app +// ./gradlew :kmp:shared-core:assembleSharedCoreReleaseXCFramework +// export FLIPCASH_SHARED_CORE_LOCAL=~/dev/bmcreations/code/code-android-app +// xed ../code-ios-app +// +// Xcode inherits the environment of whatever launched it, so it has to be started from a shell +// that exported the variable rather than from the Dock. SwiftPM re-evaluates this manifest when +// the variable changes, so switching back needs no clean — but the assemble task has to be re-run +// after every Kotlin edit, because the override points at a file on disk and SwiftPM has no way to +// know it went stale. The full loop is the orchestrator's `docs/shared-core-local-development.md`. +// +// All three manifests that consume `SharedCoreKit` read this — `FlipcashCore`, `FlipcashUI` and +// `CrossPlatformVectors` — and they have to move together. SPM resolves one version for the whole +// graph, so a graph with a path dependency in one manifest and the remote in another fails to +// resolve, with a message about conflicting requirements that does not name the cause. +// +// For the same reason, `SharedCoreKit` must not be added to a target's package dependencies inside +// `Code.xcodeproj`: that reference resolves under identity `flipcash-shared-core-spm` and nothing +// an environment variable can reach rewrites it, so the two identities collide on duplicate +// targets. An app-target import belongs behind a `FlipcashCore` or `FlipcashUI` declaration. +// +// Local mode drops the pinned entry from the tracked workspace `Package.resolved` and changes its +// `originHash`, because a path dependency has no revision to pin. Restore it before committing: +// +// git checkout -- Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +// +// Unset is the committed state, and CI never sets it, so release builds always resolve the pinned +// version below. +let sharedCoreLocalRoot = ProcessInfo.processInfo.environment["FLIPCASH_SHARED_CORE_LOCAL"] + .map { ($0 as NSString).expandingTildeInPath } + .flatMap { $0.isEmpty ? nil : $0 } + +// A path dependency takes its identity from the directory's basename rather than the `Package(name:)` +// inside it, so under the override the package is `spm`. Product references have to follow. +let sharedCorePackage = sharedCoreLocalRoot == nil ? "flipcash-shared-core-spm" : "spm" + +let sharedCore: Package.Dependency = { + if let sharedCoreLocalRoot { + return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") + } + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")) +}() let package = Package( name: "FlipcashCore", @@ -27,7 +76,7 @@ let package = Package( .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "2.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), .package(path: "../FlipcashAPI"), - .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")), + sharedCore, // Branch-pinned to match the app project's own reference to the same fork. SPM resolves one // version of it for the whole graph, so the two have to agree. .package(url: "https://github.com/dbart01/SQLite.swift", branch: "master"), @@ -43,7 +92,7 @@ let package = Package( .product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "FlipcashAPI", package: "FlipcashAPI"), - .product(name: "SharedCoreKit", package: "flipcash-shared-core-spm"), + .product(name: "SharedCoreKit", package: sharedCorePackage), ], resources: [ .copy("Resources/discrete_pricing_table.bin"), diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 415f18f8e..70deaa4b3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -14,6 +14,7 @@ public enum MessageCapability: String, Hashable, Sendable, Codable, CaseIterable case reply case edit case delete + case report } extension MessageCapability { @@ -25,14 +26,17 @@ extension MessageCapability { case .reply: "Reply" case .edit: "Edit" case .delete: "Delete" + case .report: "Report" } } /// Whether the menu should render this row in its destructive style. public var isDestructive: Bool { switch self { - case .copy, .reply, .edit: false - case .delete: true + // Report is not destructive, though a reviewer will expect it to be: nothing is destroyed + // and nothing is irreversible from the reporter's side. The red row belongs to delete. + case .copy, .reply, .edit, .report: false + case .delete: true } } } @@ -42,6 +46,17 @@ extension MessageCapability { /// The capabilities `selfUserID` has over `message`. Pure — the same inputs always give the same /// answer, which is what lets the transcript mapper run off the main actor. /// + /// Reporting follows one rule, held in step with Android's `resolveCapabilities`: anything a + /// participant sent can be reported; anything that no longer exists, or that you sent, cannot. + /// + /// | Message | Report? | + /// |---|---| + /// | Another participant's text | yes | + /// | Another participant's cash | yes | + /// | Own message, confirmed | no | + /// | Own message, unconfirmed | no | + /// | A tombstone | no | + /// /// `conversation` is accepted but unread today: it is the seam a group-chat permissions model /// plugs into (an admin deleting another member's message, a read-only channel), and taking it /// now means adding that model does not change every call site. It is optional because the @@ -63,14 +78,15 @@ extension MessageCapability { case .cash: // Reply is a cash message's only capability: there is no text to copy, the server // authored it so there is nothing to edit, and delete is deliberately withheld from - // a payment record. - return [.reply] + // a payment record. Someone else's payment is still reportable — a payment is a thing + // a person did to you. + return message.isFromSelf(selfUserID) ? [.reply] : [.reply, .report] case .text: break } guard message.isFromSelf(selfUserID) else { - return [.copy, .reply] + return [.copy, .reply, .report] } // An unconfirmed message has no `eventSequence` to send as `expected_event_sequence`, so no diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift index 9dd260aef..4a2f8ca24 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift @@ -83,11 +83,17 @@ public struct MessagePolicy: Hashable, Sendable { } /// The window governing `capability`, or `nil` when it never lapses. + /// + /// Reporting has no window on purpose. The edit and delete windows exist because the server + /// enforces them; nothing in the contract limits how old a reportable message may be. It also + /// matters mechanically: ``MessageCapability/nextExpiry(among:in:as:policy:now:)`` walks every + /// resolved capability and skips the ones with no window, so a windowed `.report` would arm a + /// timer on every incoming message in the transcript. public func window(for capability: MessageCapability) -> TimeInterval? { switch capability { - case .edit: editWindow - case .delete: deleteWindow - case .copy, .reply: nil + case .edit: editWindow + case .delete: deleteWindow + case .copy, .reply, .report: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/SharedCoreExports.swift b/FlipcashCore/Sources/FlipcashCore/SharedCoreExports.swift new file mode 100644 index 000000000..e62d7b1a7 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/SharedCoreExports.swift @@ -0,0 +1,7 @@ +// Re-exported so the app target can name the shared vocabulary -- `ReportReason` and +// `ReportDescription` -- through `import FlipcashCore`, the way it already reaches `Logging`. +// +// The app target must NOT depend on `SharedCoreKit` directly: adding it to an Xcode target's +// package dependencies gives the package a second identity, and the build fails with +// `multiple similar targets 'SharedCore', 'SharedCoreKit'`. See FlipcashCore/Package.swift. +@_exported import SharedCoreKit diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index 9d9677ccd..804cfd638 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -38,9 +38,9 @@ struct MessageCapabilityTests { #expect(resolve(text("hi", from: me, eventSequence: 0)).isEmpty) } - @Test("Someone else's text can only be copied") - func otherPersonsTextIsCopyOnly() { - #expect(resolve(text("hi", from: them)) == [.copy, .reply]) + @Test("Someone else's text can be copied, replied to, and reported") + func otherPersonsTextIsReportable() { + #expect(resolve(text("hi", from: them)) == [.copy, .reply, .report]) } @Test("A tombstone offers nothing") @@ -53,14 +53,22 @@ struct MessageCapabilityTests { #expect(resolve(tombstone).isEmpty) } - @Test("A cash message offers Reply and nothing else") - func cashMessageOffersReplyOnly() { - let cash = ConversationMessage( - id: MessageID(value: 3), senderID: me, + @Test("My own cash message offers Reply and nothing else") + func ownCashMessageOffersReplyOnly() { + #expect(resolve(cash(from: me)) == [.reply]) + } + + @Test("Someone else's cash message can be reported — a payment is a thing a person did to you") + func otherPersonsCashIsReportable() { + #expect(resolve(cash(from: them)) == [.reply, .report]) + } + + private func cash(from sender: UUID) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 3), senderID: sender, content: .cash(ExchangedFiat(nativeAmount: .usd(20), rate: .oneToOne)), cashAction: .sent, date: now, unreadSeq: 1, eventSequence: 2 ) - #expect(resolve(cash) == [.reply]) } // MARK: - Windows - @@ -109,6 +117,18 @@ struct MessageCapabilityTests { #expect(resolve(text("hi", from: me, sentAgo: 172_800.001), policy: policy) == [.copy, .reply]) } + @Test("Report has no window — it is a limit on mutating a message, and reporting mutates nothing") + func reportIsNeverWindowed() { + #expect(MessagePolicy.default.window(for: .report) == nil) + #expect(windows(edit: 60, delete: 60).window(for: .report) == nil) + // The consequence that matters: a windowed `.report` would arm a timer on every incoming + // message in the transcript, since every one of them resolves it. + #expect(MessageCapability.nextExpiry( + among: [text("hi", from: them, sentAgo: 10_000_000)], + in: nil, as: me, policy: windows(edit: 60, delete: 60), now: now + ) == nil) + } + @Test("A nil window never lapses") func nilWindowNeverLapses() { let policy = windows(edit: nil, delete: nil) @@ -211,6 +231,7 @@ struct MessageCapabilityTests { @Test("Messages with nothing to lose contribute no deadline") func nonExpiringMessagesScheduleNothing() { let policy = windows(edit: 900, delete: 172_800) + // `theirs` resolves `.report`, which has no window, so it still contributes nothing. let theirs = text("hi", from: them) let unconfirmed = text("hi", from: me, eventSequence: 0) #expect(MessageCapability.nextExpiry( diff --git a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift index 84872065a..a18e787d5 100644 --- a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift +++ b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift @@ -105,6 +105,40 @@ struct MessageCapabilityMenuTests { #expect(notified.map(\.1) == [.edit, .delete]) } + @Test("Report renders last, and is not marked destructive") + func reportIsOfferedButNotDestructive() { + let controller = loadedController([ + .message(ChatMessage(id: "1", text: "hi", sender: .other, actions: [.copy, .reply, .report])) + ]) + #expect(titles(menu(controller, at: 0)) == ["Copy", "Reply", "Report"]) + + guard let report = menu(controller, at: 0)?.children + .compactMap({ $0 as? UIAction }) + .first(where: { $0.title == "Report" }) else { + Issue.record("expected a Report action") + return + } + #expect(!report.attributes.contains(.destructive)) + } + + @Test("Report reports the row's id to the screen") + func reportNotifies() { + let controller = loadedController([ + .message(ChatMessage(id: "row-9", text: "hi", sender: .other, actions: [.report])) + ]) + var notified: [(String, MessageCapability)] = [] + controller.onMessageAction = { notified.append(($0, $1)) } + + guard let report = menu(controller, at: 0)?.children.first as? UIAction else { + Issue.record("expected a Report action") + return + } + report.performWithSender(nil, target: nil) + + #expect(notified.map(\.0) == ["row-9"]) + #expect(notified.map(\.1) == [.report]) + } + @Test("A deleted placeholder offers no menu") func tombstoneOffersNoMenu() { let controller = loadedController([ diff --git a/FlipcashTests/Chat/ReportFlowTests.swift b/FlipcashTests/Chat/ReportFlowTests.swift new file mode 100644 index 000000000..7b2c6b57d --- /dev/null +++ b/FlipcashTests/Chat/ReportFlowTests.swift @@ -0,0 +1,97 @@ +// +// ReportFlowTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import FlipcashCore +@testable import Flipcash + +// `@MainActor` for the same reason `MessageCapabilityMenuTests` is: the labels are an app-target +// extension, so their isolation is the app's default. +@MainActor +@Suite("Report flow") +struct ReportFlowTests { + + // MARK: - Which step a reason leads to + + @Test("Something Else collects details; every other reason files on confirm") + func onlyOtherNeedsDetails() { + for reason in ReportReason.displayOrder where reason != .other { + #expect( + ReportFlowState.needsDetails(reason) == false, + "\(reason.title) should file without a second step" + ) + } + #expect(ReportFlowState.needsDetails(.other)) + } + + @Test("The button says which of the two it will do") + func buttonTitleNamesTheOutcome() { + #expect(ReportFlowState.buttonTitle(for: .other) == "Next") + for reason in ReportReason.displayOrder where reason != .other { + #expect(ReportFlowState.buttonTitle(for: reason) == "Submit Report") + } + // Nothing picked yet: the button is disabled, but it should not be blank while it waits. + #expect(ReportFlowState.buttonTitle(for: nil) == "Submit Report") + } + + @Test("Whitespace is not an answer") + func detailsMustSaySomething() { + #expect(ReportFlowState.canSubmitDetails("") == false) + #expect(ReportFlowState.canSubmitDetails(" \n\t ") == false) + #expect(ReportFlowState.canSubmitDetails(" they kept messaging me ")) + } + + // MARK: - Every reason has words on it + + // The compiler cannot check a `switch` over a Kotlin enum for exhaustiveness, so this stands in + // for one: a reason added upstream arrives in `displayOrder` and fails here. + @Test("Every reason carries a label, a line of scope, and a slug") + func presentationIsTotal() { + for reason in ReportReason.displayOrder { + #expect(reason.title.isEmpty == false, "a reason is showing no label") + #expect(reason.summary.isEmpty == false, "\(reason.title) has no line of scope") + #expect(reason.identifier.isEmpty == false, "\(reason.title) has no slug") + } + + let identifiers = Set(ReportReason.displayOrder.map(\.identifier)) + #expect( + identifiers.count == ReportReason.displayOrder.count, + "two reasons share an accessibility identifier" + ) + } + + // MARK: - What reaches the wire + + // These read the shared builder rather than anything here: they fail if iOS ever starts + // assembling the `description` itself, which is the drift the shared module exists to prevent. + + @Test("No details is the bare token, with nothing trailing it") + func blankDetailsGiveABareToken() { + let built = ReportDescription.build(reason: .spam, details: nil) + #expect(built.contains("\n") == false) + #expect(built == ReportDescription.build(reason: .spam, details: " ")) + } + + @Test("Details follow the token on the next line, trimmed") + func detailsFollowTheToken() { + let built = ReportDescription.build(reason: .other, details: " they kept messaging me ") + let lines = built.components(separatedBy: "\n") + #expect(lines.count == 2) + #expect(lines[1] == "they kept messaging me") + // The first line is the token alone, so a server-side reader can split on the newline. + #expect(lines[0] == ReportDescription.build(reason: .other, details: nil)) + } + + @Test("Over-long details are truncated, not refused") + func longDetailsAreTruncated() { + let cap = Int(ReportDescription.maxDetailsLength) + let built = ReportDescription.build(reason: .other, details: String(repeating: "a", count: cap + 50)) + let lines = built.components(separatedBy: "\n") + #expect(lines.count == 2) + #expect(lines[1].count == cap) + } +} diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index 5012d0fee..9ba67b984 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -2,6 +2,26 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +import Foundation + +// `FLIPCASH_SHARED_CORE_LOCAL` swaps the pinned package for a path dependency into a +// `code-android-app` checkout. `FlipcashCore/Package.swift` carries the full rationale and the +// loop; the one thing to know here is that all three manifests consuming `SharedCoreKit` have to +// move together, because SPM resolves one version for the whole graph. +let sharedCoreLocalRoot = ProcessInfo.processInfo.environment["FLIPCASH_SHARED_CORE_LOCAL"] + .map { ($0 as NSString).expandingTildeInPath } + .flatMap { $0.isEmpty ? nil : $0 } + +// A path dependency takes its identity from the directory's basename rather than the `Package(name:)` +// inside it, so under the override the package is `spm`. Product references have to follow. +let sharedCorePackage = sharedCoreLocalRoot == nil ? "flipcash-shared-core-spm" : "spm" + +let sharedCore: Package.Dependency = { + if let sharedCoreLocalRoot { + return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") + } + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")) +}() let package = Package( name: "FlipcashUI", @@ -19,7 +39,7 @@ let package = Package( .package(url: "https://github.com/ekazaev/ChatLayout", from: "2.4.2"), .package(url: "https://github.com/ra1028/DifferenceKit", from: "1.3.0"), .package(url: "https://github.com/onevcat/Kingfisher", from: "8.3.0"), - .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")), + sharedCore, ], targets: [ .target( @@ -29,7 +49,7 @@ let package = Package( .product(name: "ChatLayout", package: "ChatLayout", condition: .when(platforms: [.iOS])), .product(name: "DifferenceKit", package: "DifferenceKit"), .product(name: "Kingfisher", package: "Kingfisher"), - .product(name: "SharedCoreKit", package: "flipcash-shared-core-spm"), + .product(name: "SharedCoreKit", package: sharedCorePackage), ], resources: [ .process("Assets") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index b91f0ff23..607736d3d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -837,7 +837,7 @@ extension ChatViewController { switch action { case .copy: if let body { UIPasteboard.general.string = body } - case .reply, .edit, .delete: + case .reply, .edit, .delete, .report: handler?(rowID, action) } } @@ -1025,6 +1025,7 @@ private extension MessageCapability { case .reply: .replyArrow case .edit: .pencil case .delete: .trash + case .report: .flag } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index c7891d5e0..296fba368 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -84,6 +84,7 @@ public enum SystemSymbol: String { case pencil = "pencil" case trash = "trash" + case flag = "flag" /// The bare glyph, for a confirm affordance that carries its own backing shape. case checkmark = "checkmark" diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Bill/CodeView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Bill/CodeView.swift index 71fbf6949..be5146380 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Bill/CodeView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Bill/CodeView.swift @@ -36,6 +36,22 @@ public struct CodeView: View { } } +extension CodeView { + + /// The same figure as a standalone SVG document, for export. + /// + /// Here rather than in the app target so that nothing outside this package reaches + /// `SharedCoreKit` directly. An app-target package reference resolves shared core under + /// identity `flipcash-shared-core-spm`, while `FLIPCASH_SHARED_CORE_LOCAL` resolves it under + /// `spm`, and a graph holding both fails to resolve at all. See `FlipcashCore/Package.swift`. + /// + /// - Parameter background: the surface color the code sits on, or `nil` for a transparent + /// document. Codes are light-on-dark, so a transparent export is invisible on light surfaces. + public static func svg(data: Data, dimension: CGFloat, background: String?) -> String { + KikCode.svg(payload: data, dimension: Double(dimension), background: background) + } +} + // MARK: - Code Figure - /// One half of a code, laid out square and centred in whatever rect it is handed.