Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Flipcash/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import UIKit
import SwiftUI
import CoreSpotlight
import FlipcashUI
import FlipcashCore

Expand Down Expand Up @@ -40,6 +41,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
])
self.container = Container()
super.init()

// Point App Intents (which run outside the SwiftUI environment) at the
// live session so the Send shortcut can reach contacts and the router.
AppIntentContext.sessionAuthenticator = container.sessionAuthenticator
}

// MARK: - Launch -
Expand Down Expand Up @@ -170,6 +175,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
handleOpenURL(url: url)
}

/// Routes a continued `NSUserActivity` — a Spotlight chat tap or a Handoff /
/// Siri-suggestion of an opened chat — into the deep-link pipeline by
/// rebuilding the `flipcash://chat/{id}` URL the activity carries.
func handleContinue(_ activity: NSUserActivity) {
let chatID: String? = switch activity.activityType {
case CSSearchableItemActionType:
activity.userInfo?[CSSearchableItemActivityIdentifier] as? String
case AppUserActivity.openChat:
activity.userInfo?[AppUserActivity.chatIDKey] as? String
default:
nil
}

guard let chatID, let url = URL(string: "flipcash://chat/\(chatID)") else {
logger.warning("User activity continuation missing chat id", metadata: ["type": "\(activity.activityType)"])
return
}

handleOpenURL(url: url)
}

// MARK: - Push Notifications -

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Expand Down
51 changes: 51 additions & 0 deletions Flipcash/Core/AppIntents/AppIntentContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// AppIntentContext.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import FlipcashCore

/// Main-actor-confined bridge from App Intents (which run outside the SwiftUI
/// environment) to the live session. `AppDelegate` points it at the
/// `SessionAuthenticator` at launch; intents and entity queries read through it.
///
/// Everything degrades to "no session": a logged-out or send-disabled state
/// yields no contacts and a `false` `canSend`, so the Send shortcut stays inert
/// until the feature is available.
@MainActor
enum AppIntentContext {

static var sessionAuthenticator: SessionAuthenticator?

private static var loggedInContainer: SessionContainer? {
guard case .loggedIn(let container) = sessionAuthenticator?.state else { return nil }
return container
}

/// Whether the signed-in user can send — gates both the contact query and
/// the intent. Mirrors `Session.canSend` (beta flag OR server flag).
static var canSend: Bool {
loggedInContainer?.session.canSend ?? false
}

/// On-Flipcash contacts the user can send to, or `[]` when logged out or
/// send is unavailable. Invite-only (non-Flipcash) contacts are excluded —
/// they can't receive cash.
static func sendableContacts() async -> [ResolvedContact] {
guard canSend, let container = loggedInContainer else { return [] }
return await RecipientLoader.load(database: container.database).onFlipcash
}

static func contact(withID id: String) async -> ResolvedContact? {
await sendableContacts().first { $0.id == id }
}

/// Foregrounds the app at the send amount-entry screen for `contact`,
/// reusing the same router path the recipient picker drives.
static func openSendFlow(to contact: ResolvedContact) {
loggedInContainer?.appRouter.navigate(to: .sendAmount(contact: contact))
}
}
65 changes: 65 additions & 0 deletions Flipcash/Core/AppIntents/ContactEntity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// ContactEntity.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import AppIntents
import FlipcashCore

/// A sendable Flipcash contact, exposed to Siri and the Shortcuts app as the
/// recipient parameter of ``SendCashIntent``. Lean by design: the live
/// `ResolvedContact` (with its image and chat id) is re-resolved by `id` at
/// `perform()` time so the entity payload stays small and never goes stale.
struct ContactEntity: AppEntity {

static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact")
static let defaultQuery = ContactEntityQuery()

/// The `ResolvedContact.id` composite (`contactId|e164`).
let id: String
let displayName: String
let nationalPhone: String

var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(displayName)", subtitle: "\(nationalPhone)")
}
}

extension ContactEntity {
init(_ contact: ResolvedContact) {
self.id = contact.id
self.displayName = contact.displayName
self.nationalPhone = contact.nationalPhone
}
}

/// Resolves ``ContactEntity`` values from the live sendable-contact list.
/// Returns nothing when the user is logged out or can't send, so the parameter
/// offers no suggestions and the shortcut stays inert.
struct ContactEntityQuery: EntityQuery {

func entities(for identifiers: [String]) async throws -> [ContactEntity] {
let wanted = Set(identifiers)
return await AppIntentContext.sendableContacts()
.filter { wanted.contains($0.id) }
.map(ContactEntity.init)
}

func suggestedEntities() async throws -> [ContactEntity] {
await AppIntentContext.sendableContacts().map(ContactEntity.init)
}
}

extension ContactEntityQuery: EntityStringQuery {
func entities(matching string: String) async throws -> [ContactEntity] {
let lowered = string.lowercased()
return await AppIntentContext.sendableContacts()
.filter {
$0.displayName.lowercased().contains(lowered)
|| $0.nationalPhone.contains(string)
}
.map(ContactEntity.init)
}
}
25 changes: 25 additions & 0 deletions Flipcash/Core/AppIntents/FlipcashShortcuts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// FlipcashShortcuts.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import AppIntents

/// Surfaces the app's intents to Siri and Spotlight without user setup. Auto-
/// discovered by the system — no Info.plist entry needed.
struct FlipcashShortcuts: AppShortcutsProvider {

static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: SendCashIntent(),
phrases: [
"Send cash with \(.applicationName)",
"Send money with \(.applicationName)",
],
shortTitle: "Send Cash",
systemImageName: "paperplane.circle.fill"
)
}
}
54 changes: 54 additions & 0 deletions Flipcash/Core/AppIntents/SendCashIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// SendCashIntent.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import AppIntents

/// Opens the app at the send amount-entry screen for a chosen contact. Launches
/// the app rather than completing the payment headlessly — entering an amount
/// and confirming always happens in-app, matching Apple's money-handling
/// guidance and the verified-state invariant a headless send would break.
///
/// `contact` is required so Siri/Spotlight prompt "who?" using the on-Flipcash
/// contacts the query offers, then proceed once one is chosen.
struct SendCashIntent: AppIntent {

static let title: LocalizedStringResource = "Send Cash"
static let description = IntentDescription("Start sending cash to one of your Flipcash contacts.")

/// Bring the app to the foreground so the user finishes the send in-app.
static let openAppWhenRun = true

@Parameter(title: "To")
var contact: ContactEntity

func perform() async throws -> some IntentResult {
guard await AppIntentContext.canSend else {
throw SendCashIntentError.sendUnavailable
}
guard let resolved = await AppIntentContext.contact(withID: contact.id) else {
throw SendCashIntentError.recipientUnavailable
}
await AppIntentContext.openSendFlow(to: resolved)
return .result()
}

static var parameterSummary: some ParameterSummary {
Summary("Send cash to \(\.$contact)")
}
}

enum SendCashIntentError: Error, CustomLocalizedStringResourceConvertible {
case sendUnavailable
case recipientUnavailable

var localizedStringResource: LocalizedStringResource {
switch self {
case .sendUnavailable: "Sending isn’t available yet."
case .recipientUnavailable: "That contact isn’t available to send to."
}
}
}
7 changes: 7 additions & 0 deletions Flipcash/Core/FlipcashApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
import CoreSpotlight
import FlipcashUI

/// The main entry point for Flipcash.
Expand All @@ -26,6 +27,12 @@ struct FlipcashApp: App {
.onOpenURL { url in
appDelegate.handleOpenURL(url: url)
}
.onContinueUserActivity(CSSearchableItemActionType) { activity in
appDelegate.handleContinue(activity)
}
.onContinueUserActivity(AppUserActivity.openChat) { activity in
appDelegate.handleContinue(activity)
}
.withDialogWindow(
sessionAuthenticator: appDelegate.container.sessionAuthenticator
)
Expand Down
13 changes: 13 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,19 @@ struct ConversationScreen: View {
hasAppeared = true
}
}
// Donate the open chat for Siri prediction, Handoff, and Spotlight.
// Only an existing chat carries an id worth resuming; a contact without
// a chat yet has nothing to hand off to.
.userActivity(AppUserActivity.openChat, isActive: chatExists && conversationID != nil) { activity in
guard let conversationID else { return }
activity.title = title
activity.userInfo = [AppUserActivity.chatIDKey: conversationID.base64URLEncoded]
activity.requiredUserInfoKeys = [AppUserActivity.chatIDKey]
activity.persistentIdentifier = conversationID.base64URLEncoded
activity.isEligibleForSearch = true
activity.isEligibleForHandoff = true
activity.isEligibleForPrediction = true
}
}

private func sendCash() {
Expand Down
9 changes: 9 additions & 0 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ final class SessionAuthenticator {
container.pushController.prepareForLogout()
container.usdcSweepOperation.cancel()
container.conversationController.stop()
container.chatSpotlightIndexer.stop()
QuickActionsController.clear()
}

Expand Down Expand Up @@ -459,6 +460,7 @@ struct SessionContainer {
let usdcSweepOperation: UsdcSweepOperation
let quickActionsController: QuickActionsController
let conversationController: ConversationController
let chatSpotlightIndexer: ChatSpotlightIndexer

init(
session: Session,
Expand Down Expand Up @@ -532,6 +534,13 @@ struct SessionContainer {
)
conversationController.start()
self.conversationController = conversationController

let chatSpotlightIndexer = ChatSpotlightIndexer(
controller: conversationController,
contactSyncController: contactSyncController
)
chatSpotlightIndexer.start()
self.chatSpotlightIndexer = chatSpotlightIndexer
}

fileprivate func injectingEnvironment<SomeView>(into view: SomeView) -> some View where SomeView: View {
Expand Down
23 changes: 23 additions & 0 deletions Flipcash/Core/Spotlight/AppUserActivity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// AppUserActivity.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation

/// Identifiers for the `NSUserActivity` the app donates and continues.
/// Shared by the donation site (`ConversationScreen`), the continuation
/// handler (`AppDelegate`), and the scene-root modifiers (`FlipcashApp`).
///
/// The activity type must also be declared in `NSUserActivityTypes` in
/// Info.plist or the system drops the donation.
enum AppUserActivity {
/// A DM conversation the user opened. Donated for Siri prediction, Handoff,
/// and Spotlight; carries the chat id under ``chatIDKey``.
static let openChat = "com.flipcash.app.openChat"

/// `userInfo` key holding a conversation's `base64URLEncoded` id.
static let chatIDKey = "conversationID"
}
Loading