From f30383a504eb13b7de8d647f89a89210a2137e73 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 11:23:18 -0400 Subject: [PATCH 1/3] feat: add home-screen quick actions for Give, Wallet, Discover Long-press the app icon to jump straight into Give, Wallet, or Discover. Each shortcut emits a flipcash:// URL handled by the existing deep link pipeline, so the same route handler covers QR codes, push notifications, and quick actions. A SceneDelegate bridges the iOS quick-action callback, which SwiftUI's App lifecycle does not forward to AppDelegate. --- Flipcash/Core/AppDelegate.swift | 18 ++++++ .../Deep Links/DeepLinkController.swift | 40 ++++++++++++- .../Core/Controllers/Deep Links/Route.swift | 9 +++ .../Controllers/NotificationController.swift | 1 + Flipcash/Core/SceneDelegate.swift | 43 ++++++++++++++ .../Core/Screens/Main/ScanViewModel.swift | 2 +- Flipcash/Supporting Files/Info.plist | 59 +++++++++++++++++++ 7 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 Flipcash/Core/SceneDelegate.swift diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index 6e23da2d..123d3417 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -80,9 +80,27 @@ class AppDelegate: UIResponder, UIApplicationDelegate { object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDeepLinkNotification(_:)), + name: .shortcutDeepLinkReceived, + object: nil + ) + return true } + /// Routes scene events to ``SceneDelegate``. SwiftUI's `App` lifecycle + /// uses its own implicit scene delegate by default, swallowing + /// `windowScene(_:performActionFor:)`; without this method, the + /// `UISceneDelegateClassName` declared in `Info.plist` is not honored + /// and quick actions are dropped. + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } + // MARK: - Lifecycle - func scenePhaseChanged(_ phase: ScenePhase) { diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 0a3d2acc..00e01dbe 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -93,11 +93,20 @@ final class DeepLinkController { case .token(let mint): return actionForCurrencyInfo(mint: mint) - + + case .give: + return actionForOpenSheet(.give) + + case .wallet: + return actionForOpenSheet(.balance) + + case .discover: + return actionForOpenSheet(.discover) + case .unknown: break } - + return nil } @@ -134,6 +143,13 @@ final class DeepLinkController { sessionAuthenticator: sessionAuthenticator ) } + + private func actionForOpenSheet(_ sheet: AppRouter.SheetPresentation) -> DeepLinkAction { + DeepLinkAction( + kind: .openSheet(sheet), + sessionAuthenticator: sessionAuthenticator + ) + } } extension DeepLinkController { @@ -212,6 +228,24 @@ struct DeepLinkAction { Analytics.deeplinkRouted(kind: kind) container.appRouter.navigate(to: .currencyInfo(mint)) } + + case .openSheet(let sheet): + if case .loggedIn(let container) = sessionAuthenticator.state { + Analytics.deeplinkRouted(kind: kind) + // Mirror `ScanScreen.presentGive` so a no-balance user + // taking the quick-action route gets the same dialog the + // bottom bar shows, instead of an empty Give sheet. + if sheet == .give { + let rate = container.ratesController.rateForBalanceCurrency() + guard container.session.hasGiveableBalance(for: rate) else { + container.session.dialogItem = .noGiveableBalance { + container.appRouter.present(.discover) + } + return + } + } + container.appRouter.present(sheet) + } } } } @@ -224,6 +258,7 @@ extension DeepLinkAction { case receiveCashLink(MnemonicPhrase) case verifyEmail(VerificationDescription) case currencyInfo(PublicKey) + case openSheet(AppRouter.SheetPresentation) } } @@ -234,6 +269,7 @@ extension DeepLinkAction.Kind { case .receiveCashLink: "CashLink" case .verifyEmail: "EmailVerification" case .currencyInfo: "TokenInfo" + case .openSheet(let sheet): "Sheet:\(sheet)" } } } diff --git a/Flipcash/Core/Controllers/Deep Links/Route.swift b/Flipcash/Core/Controllers/Deep Links/Route.swift index 589e26af..80ad0836 100644 --- a/Flipcash/Core/Controllers/Deep Links/Route.swift +++ b/Flipcash/Core/Controllers/Deep Links/Route.swift @@ -85,6 +85,9 @@ nonisolated extension Route { case cash case verifyEmail case token(PublicKey) + case give + case wallet + case discover case unknown(String) static func parse(path: String) -> Path? { @@ -111,6 +114,12 @@ nonisolated extension Route { return nil } return .token(mint) + case "give": + return .give + case "wallet": + return .wallet + case "discover": + return .discover default: return .unknown(url.lastPathComponent) } diff --git a/Flipcash/Core/Controllers/NotificationController.swift b/Flipcash/Core/Controllers/NotificationController.swift index e067a90d..448fdb0d 100644 --- a/Flipcash/Core/Controllers/NotificationController.swift +++ b/Flipcash/Core/Controllers/NotificationController.swift @@ -69,5 +69,6 @@ extension NSNotification.Name { static let pushNotificationWillPresent = Notification.Name("com.code.pushNotificationWillPresent") static let pushDeepLinkReceived = Notification.Name("com.code.pushDeepLinkReceived") static let qrDeepLinkReceived = Notification.Name("com.code.qrDeepLinkReceived") + static let shortcutDeepLinkReceived = Notification.Name("com.code.shortcutDeepLinkReceived") static let messageNotificationReceived = Notification.Name("com.code.messageNotificationReceived") } diff --git a/Flipcash/Core/SceneDelegate.swift b/Flipcash/Core/SceneDelegate.swift new file mode 100644 index 00000000..2282ccfa --- /dev/null +++ b/Flipcash/Core/SceneDelegate.swift @@ -0,0 +1,43 @@ +// +// SceneDelegate.swift +// Flipcash +// + +import UIKit +import FlipcashCore + +private let logger = Logger(label: "flipcash.scene-delegate") + +/// Bridges quick-action taps into the existing deep-link pipeline. SwiftUI's +/// `App` lifecycle doesn't forward `UIApplicationShortcutItem` events to +/// `AppDelegate`, so we receive them here, pull the embedded URL out of the +/// shortcut's `userInfo`, and post the same notification the rest of the +/// app's URL handlers already observe. +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + if let shortcut = connectionOptions.shortcutItem { + postDeepLink(for: shortcut) + } + } + + func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + completionHandler(postDeepLink(for: shortcutItem)) + } + + @discardableResult + private func postDeepLink(for shortcut: UIApplicationShortcutItem) -> Bool { + guard let urlString = shortcut.userInfo?["url"] as? String, + let url = URL(string: urlString) else { + logger.warning("Quick action missing url userInfo", metadata: ["type": "\(shortcut.type)"]) + return false + } + NotificationCenter.default.post( + name: .shortcutDeepLinkReceived, + object: nil, + userInfo: ["url": url] + ) + return true + } +} diff --git a/Flipcash/Core/Screens/Main/ScanViewModel.swift b/Flipcash/Core/Screens/Main/ScanViewModel.swift index 931848c1..9099e3f2 100644 --- a/Flipcash/Core/Screens/Main/ScanViewModel.swift +++ b/Flipcash/Core/Screens/Main/ScanViewModel.swift @@ -116,7 +116,7 @@ class ScanViewModel { switch route.path { case .cash, .token: return true - case .login, .verifyEmail, .unknown: + case .login, .verifyEmail, .give, .wallet, .discover, .unknown: return false } } diff --git a/Flipcash/Supporting Files/Info.plist b/Flipcash/Supporting Files/Info.plist index ff9fc3be..e53c156c 100644 --- a/Flipcash/Supporting Files/Info.plist +++ b/Flipcash/Supporting Files/Info.plist @@ -21,6 +21,65 @@ SQLiteVersion 11 + UIApplicationShortcutItems + + + UIApplicationShortcutItemType + com.flipcash.shortcut.give + UIApplicationShortcutItemTitle + Give + UIApplicationShortcutItemIconSymbolName + banknote + UIApplicationShortcutItemUserInfo + + url + flipcash://give + + + + UIApplicationShortcutItemType + com.flipcash.shortcut.wallet + UIApplicationShortcutItemTitle + Wallet + UIApplicationShortcutItemIconSymbolName + wallet.bifold + UIApplicationShortcutItemUserInfo + + url + flipcash://wallet + + + + UIApplicationShortcutItemType + com.flipcash.shortcut.discover + UIApplicationShortcutItemTitle + Discover + UIApplicationShortcutItemIconSymbolName + binoculars + UIApplicationShortcutItemUserInfo + + url + flipcash://discover + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + UILaunchScreen UIColorName From 692e7566da2d86014ea98256d83c7fa82c060a14 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 11:40:17 -0400 Subject: [PATCH 2/3] refactor: extract SessionContainer.presentGive helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-bar and quick-action paths both opened Give the same way — rate lookup, giveable-balance check, no-balance dialog or sheet. Collapse to a single SessionContainer method called from both sites. --- .../Deep Links/DeepLinkController.swift | 14 +++----------- Flipcash/Core/Screens/Main/ScanScreen.swift | 9 +-------- Flipcash/Core/Session/SessionAuthenticator.swift | 13 +++++++++++++ 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 00e01dbe..e32d6e0b 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -232,19 +232,11 @@ struct DeepLinkAction { case .openSheet(let sheet): if case .loggedIn(let container) = sessionAuthenticator.state { Analytics.deeplinkRouted(kind: kind) - // Mirror `ScanScreen.presentGive` so a no-balance user - // taking the quick-action route gets the same dialog the - // bottom bar shows, instead of an empty Give sheet. if sheet == .give { - let rate = container.ratesController.rateForBalanceCurrency() - guard container.session.hasGiveableBalance(for: rate) else { - container.session.dialogItem = .noGiveableBalance { - container.appRouter.present(.discover) - } - return - } + container.presentGive() + } else { + container.appRouter.present(sheet) } - container.appRouter.present(sheet) } } } diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index 47243bb4..a16f7dac 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -305,14 +305,7 @@ struct ScanScreen: View { // MARK: - Actions - private func presentGive() { - let rate = sessionContainer.ratesController.rateForBalanceCurrency() - guard session.hasGiveableBalance(for: rate) else { - session.dialogItem = .noGiveableBalance( - onDiscover: { router.present(.discover) } - ) - return - } - router.present(.give) + sessionContainer.presentGive() } private func dismissBill() { diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 3705aae0..b3034259 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -452,6 +452,19 @@ struct SessionContainer { .environment(onrampDeeplinkInbox) } + /// Opens the Give sheet, or surfaces the no-balance dialog with a path + /// to Discover when the user has nothing giveable. Used by both the + /// bottom-bar tap and the `flipcash://give` deep link. + func presentGive() { + let rate = ratesController.rateForBalanceCurrency() + guard session.hasGiveableBalance(for: rate) else { + session.dialogItem = .noGiveableBalance( + onDiscover: { self.appRouter.present(.discover) } + ) + return + } + appRouter.present(.give) + } } extension View { From 6220dab2a277964dd0048c04ea5887a0b5cbc1e3 Mon Sep 17 00:00:00 2001 From: Raul Riera Date: Wed, 13 May 2026 11:54:54 -0400 Subject: [PATCH 3/3] refactor: inline presentGive at both call sites SessionContainer is a DI container, not a place for business logic. Drop the helper and keep the guard inline in ScanScreen and DeepLinkController. --- .../Deep Links/DeepLinkController.swift | 11 ++++++++--- Flipcash/Core/Screens/Main/ScanScreen.swift | 9 ++++++++- Flipcash/Core/Session/SessionAuthenticator.swift | 14 -------------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index e32d6e0b..ee3a9dfe 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -233,10 +233,15 @@ struct DeepLinkAction { if case .loggedIn(let container) = sessionAuthenticator.state { Analytics.deeplinkRouted(kind: kind) if sheet == .give { - container.presentGive() - } else { - container.appRouter.present(sheet) + let rate = container.ratesController.rateForBalanceCurrency() + guard container.session.hasGiveableBalance(for: rate) else { + container.session.dialogItem = .noGiveableBalance( + onDiscover: { container.appRouter.present(.discover) } + ) + return + } } + container.appRouter.present(sheet) } } } diff --git a/Flipcash/Core/Screens/Main/ScanScreen.swift b/Flipcash/Core/Screens/Main/ScanScreen.swift index a16f7dac..47243bb4 100644 --- a/Flipcash/Core/Screens/Main/ScanScreen.swift +++ b/Flipcash/Core/Screens/Main/ScanScreen.swift @@ -305,7 +305,14 @@ struct ScanScreen: View { // MARK: - Actions - private func presentGive() { - sessionContainer.presentGive() + let rate = sessionContainer.ratesController.rateForBalanceCurrency() + guard session.hasGiveableBalance(for: rate) else { + session.dialogItem = .noGiveableBalance( + onDiscover: { router.present(.discover) } + ) + return + } + router.present(.give) } private func dismissBill() { diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index b3034259..1a30cec4 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -451,20 +451,6 @@ struct SessionContainer { .environment(onrampCoordinator) .environment(onrampDeeplinkInbox) } - - /// Opens the Give sheet, or surfaces the no-balance dialog with a path - /// to Discover when the user has nothing giveable. Used by both the - /// bottom-bar tap and the `flipcash://give` deep link. - func presentGive() { - let rate = ratesController.rateForBalanceCurrency() - guard session.hasGiveableBalance(for: rate) else { - session.dialogItem = .noGiveableBalance( - onDiscover: { self.appRouter.present(.discover) } - ) - return - } - appRouter.present(.give) - } } extension View {