diff --git a/apps/bare-expo/app.json b/apps/bare-expo/app.json index d88d07d063ef28..ee7ef795cc0a81 100644 --- a/apps/bare-expo/app.json +++ b/apps/bare-expo/app.json @@ -52,6 +52,12 @@ "expo-router", "expo-video", "expo-background-fetch", + [ + "expo-app-intents", + { + "directory": "../native-component-list/app-intents" + } + ], [ "expo-splash-screen", { @@ -119,8 +125,10 @@ "inlineModules": { "watchedDirectories": [ "../native-component-list/src/screens/inlineModules/inlineModulesExamples", - "../native-component-list/src/screens/ModulesCore" - ] + "../native-component-list/src/screens/ModulesCore", + "../native-component-list/app-intents" + ], + "xcodeProjectTargets": ["BareExpo"] } } } diff --git a/apps/bare-expo/app/_layout.tsx b/apps/bare-expo/app/_layout.tsx index 75ad994cd38dd6..99ce411b6a45e1 100644 --- a/apps/bare-expo/app/_layout.tsx +++ b/apps/bare-expo/app/_layout.tsx @@ -54,6 +54,10 @@ const getSearchScreenOptions = optionalRequire(() => require('native-component-list/src/screens/SearchScreen') )?.getSearchScreenOptions; +const AppIntentsNavigationHandler = optionalRequire(() => + require('native-component-list/src/screens/AppIntents/AppIntentsNavigationHandler') +)?.AppIntentsNavigationHandler; + function useLoaded() { const [isLoaded, setLoaded] = React.useState(false); React.useEffect(() => { @@ -100,6 +104,7 @@ function RootLayout() { + {AppIntentsNavigationHandler ? : null} ); diff --git a/apps/bare-expo/ios/BareExpo.xcodeproj/project.pbxproj b/apps/bare-expo/ios/BareExpo.xcodeproj/project.pbxproj index 4b6063c17db3d4..18f06f02afb601 100644 --- a/apps/bare-expo/ios/BareExpo.xcodeproj/project.pbxproj +++ b/apps/bare-expo/ios/BareExpo.xcodeproj/project.pbxproj @@ -70,6 +70,17 @@ path = "../../native-component-list/src/screens/ModulesCore"; sourceTree = SOURCE_ROOT; }; + E2A1B95CA00D4E4CBBC49B09 /* ../../native-component-list/app-intents */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = "../../native-component-list/app-intents"; + sourceTree = SOURCE_ROOT; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -145,6 +156,7 @@ AC3B6E5EB4FE4A6AACD31066 /* Resources */, A33828129C2D4325846AC204 /* ../../native-component-list/src/screens/inlineModules/inlineModulesExamples */, A33828129C2D4325846AC205 /* ../../native-component-list/src/screens/ModulesCore */, + E2A1B95CA00D4E4CBBC49B09 /* ../../native-component-list/app-intents */, ); indentWidth = 2; sourceTree = ""; @@ -251,6 +263,7 @@ fileSystemSynchronizedGroups = ( A33828129C2D4325846AC204 /* ../../native-component-list/src/screens/inlineModules/inlineModulesExamples */, A33828129C2D4325846AC205 /* ../../native-component-list/src/screens/ModulesCore */, + E2A1B95CA00D4E4CBBC49B09 /* ../../native-component-list/app-intents */, ); name = BareExpo; productName = BareExpo; diff --git a/apps/bare-expo/ios/Podfile.lock b/apps/bare-expo/ios/Podfile.lock index 51e971d9f26af5..0b0afe653ec763 100644 --- a/apps/bare-expo/ios/Podfile.lock +++ b/apps/bare-expo/ios/Podfile.lock @@ -4048,7 +4048,7 @@ SPEC CHECKSUMS: expo-dev-menu-interface: 7a59e803916fbfd1b96c75ff91ea8dddac7e722f ExpoAgeRange: f30bef82f1a99c097e28f50ed41ca04d5e1cab1c ExpoAppIntegrity: c4f6d2065674ec9744881f8c04d52ade9b6cacd8 - ExpoAppIntents: e4702a43af248c7f72ffe4855635f8d0319d330a + ExpoAppIntents: ca59c27e6cb5093cc05e82a44211d8595d25e91d ExpoAppleAuthentication: 97d113f0f2680067e6e76d3c9249e7e1fc026b42 ExpoAppMetrics: edb2e75dba50059ba781c56b8ff0824aaeb9e74b ExpoAsset: a76534cf7b762978861dd31708308496c676166f diff --git a/apps/bare-expo/ios/Podfile.properties.json b/apps/bare-expo/ios/Podfile.properties.json index 969940864c8aa3..8f5295ec9db110 100644 --- a/apps/bare-expo/ios/Podfile.properties.json +++ b/apps/bare-expo/ios/Podfile.properties.json @@ -2,8 +2,8 @@ "expo.jsEngine": "hermes", "newArchEnabled": "true", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", - "expo.inlineModules.watchedDirectories": "[\"../native-component-list/src/screens/inlineModules/inlineModulesExamples\",\"../native-component-list/src/screens/ModulesCore\"]", - "expo.inlineModules.xcodeProjectTargets": "{\"mainTarget\":\"BareExpo\",\"targets\":[]}", + "expo.inlineModules.watchedDirectories": "[\"../native-component-list/src/screens/inlineModules/inlineModulesExamples\",\"../native-component-list/src/screens/ModulesCore\",\"../native-component-list/app-intents\"]", + "expo.inlineModules.xcodeProjectTargets": "{\"targets\":[\"BareExpo\"]}", "ios.buildReactNativeFromSource": "false", "EXPO_USE_PRECOMPILED_MODULES": "false" } diff --git a/apps/expo-go/android/build.gradle b/apps/expo-go/android/build.gradle index a8d7e0cd5defac..3ea5df6a8540b9 100644 --- a/apps/expo-go/android/build.gradle +++ b/apps/expo-go/android/build.gradle @@ -91,6 +91,20 @@ project(":packages:react-native:ReactAndroid").afterEvaluate { reactAndroid -> reactAndroid.tasks.named("buildCodegenCLI").configure { it.enabled = false } } +// Every module's generateCodegenSchemaFromJavaScript reads react-native-codegen/lib +// directly, so build it once when it is missing. +def labCodegenDir = file("$rootDir/../../../react-native-lab/react-native/packages/react-native-codegen") +tasks.register("buildLabCodegenCli", Exec) { + onlyIf { !new File(labCodegenDir, "lib/cli/combine/combine-js-to-schema-cli.js").exists() } + workingDir labCodegenDir + commandLine "bash", "scripts/oss/build.sh" +} +allprojects { + tasks.matching { it.name == "generateCodegenSchemaFromJavaScript" }.configureEach { + it.dependsOn(":buildLabCodegenCli") + } +} + // Expo Go ships a release build that keeps the JS debugger. React Native gates those native defines // behind REACT_NATIVE_DEBUG_OPTIMIZED, and its CMake guard only passes automatically when // CMAKE_BUILD_TYPE is Debug. Release maps to RelWithDebInfo, which does not match, so set the flag diff --git a/apps/native-component-list/app-intents/AppIntentsSetup.swift b/apps/native-component-list/app-intents/AppIntentsSetup.swift new file mode 100644 index 00000000000000..1405bb09c261e2 --- /dev/null +++ b/apps/native-component-list/app-intents/AppIntentsSetup.swift @@ -0,0 +1,19 @@ +internal import ExpoAppIntents +internal import ExpoModulesCore + +/// Registered Expo inline module that wires app-target App Intents code to expo-app-intents. +/// Do not change the name of this class. +final class AppIntentsSetup: Module { + public func definition() -> ExpoModulesCore.ModuleDefinition { + Name("AppIntentsSetup") + + OnCreate { + Task { + await AppIntentDispatcher.shared.setShortcutsRefreshHandler { + AppShortcuts.updateAppShortcutParameters() + } + AppShortcuts.updateAppShortcutParameters() + } + } + } +} diff --git a/apps/native-component-list/app-intents/AppShortcuts.swift b/apps/native-component-list/app-intents/AppShortcuts.swift new file mode 100644 index 00000000000000..935a27850f3098 --- /dev/null +++ b/apps/native-component-list/app-intents/AppShortcuts.swift @@ -0,0 +1,33 @@ +import AppIntents + +/// All App Shortcuts for this app. Phrases are compiled into the app and cannot be created +/// at runtime. Required parameters that are not in the launch phrase are collected by Siri +/// as follow-up questions. +/// +/// System rules: +/// - Every phrase must include \(.applicationName) or the phrase is dropped at build time. +/// - At most 10 App Shortcuts per app. Apple recommends 2-5. +struct AppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: IncreaseCounterIntent(), + phrases: [ + "Increase the counter in \(.applicationName)" + ], + shortTitle: "Increase Counter", + systemImageName: "plus.circle" + ) + + AppShortcut( + intent: OrderFoodIntent(), + phrases: [ + "Place an order in \(.applicationName)", + "Order food in \(.applicationName)", + "Order \(\.$dish) in \(.applicationName)", + "Place an order for \(\.$dish) in \(.applicationName)", + ], + shortTitle: "Place an order", + systemImageName: "fork.knife" + ) + } +} diff --git a/apps/native-component-list/app-intents/CreateDraftIntent.swift b/apps/native-component-list/app-intents/CreateDraftIntent.swift new file mode 100644 index 00000000000000..49766fd0c4000c --- /dev/null +++ b/apps/native-component-list/app-intents/CreateDraftIntent.swift @@ -0,0 +1,46 @@ +import AppIntents +internal import ExpoAppIntents +import Foundation + +/// On devices running the new AI Siri, the `CreateDraftIntent` should be automatically +/// picked up by the system without the need of registering it in AppShortcutProvider phrases. +@available(iOS 18.0, *) +@AppIntent(schema: .mail.createDraft) +struct CreateDraftIntent { + static let openAppWhenRun: Bool = true + + var body: AttributedString? + var to: [IntentPerson] + var subject: String? + var cc: [IntentPerson] + var bcc: [IntentPerson] + var account: MailAccountEntity? + var attachments: [IntentFile] + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + let draft = MailDraftEntity( + id: UUID().uuidString, + to: to, + cc: cc, + bcc: bcc, + subject: subject, + body: body, + attachments: attachments, + account: account ?? MailAccountEntity.default + ) + + await AppIntentDispatcher.shared.dispatch( + name: "createMailDraft", + params: [ + "id": .string(draft.id), + "subject": .string(subject ?? ""), + "body": .string(draft.bodyText), + "recipients": .array(draft.recipientAddresses.map(AppIntentValue.string)), + "attachmentCount": .int(attachments.count), + ] + ) + + return .result(value: draft) + } +} diff --git a/apps/native-component-list/app-intents/DeleteDraftIntent.swift b/apps/native-component-list/app-intents/DeleteDraftIntent.swift new file mode 100644 index 00000000000000..512376fb349874 --- /dev/null +++ b/apps/native-component-list/app-intents/DeleteDraftIntent.swift @@ -0,0 +1,30 @@ +import AppIntents +internal import ExpoAppIntents +import Foundation + +/// Deletes one or more drafts. The `.mail.deleteDraft` schema supplies its own confirmation +/// dialog ("Are you sure you would like to delete ... ?"), so this intent does not ask again, +/// and it does not open the app: the invocation is queued and JavaScript applies it the next +/// time it runs. +/// +/// The schema requires an authentication policy at least as restrictive as +/// `.requiresLocalDeviceAuthentication`, because deleting content is destructive. +@available(iOS 18.0, *) +@AppIntent(schema: .mail.deleteDraft) +struct DeleteDraftIntent { + static let authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication + + var entities: [MailDraftEntity] + + @MainActor + func perform() async throws -> some IntentResult { + await AppIntentDispatcher.shared.dispatch( + name: "deleteMailDrafts", + params: [ + "ids": .array(entities.map { .string($0.id) }) + ] + ) + + return .result() + } +} diff --git a/apps/native-component-list/app-intents/Entities/DishEntity.swift b/apps/native-component-list/app-intents/Entities/DishEntity.swift new file mode 100644 index 00000000000000..647680310aca3b --- /dev/null +++ b/apps/native-component-list/app-intents/Entities/DishEntity.swift @@ -0,0 +1,64 @@ +import AppIntents +internal import ExpoAppIntents +import Foundation + +private func normalizeDishText(_ value: String) -> String { + return + value + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .trimmingCharacters(in: .whitespacesAndNewlines) +} + +struct DishEntity: AppEntity { + static let typeDisplayRepresentation: TypeDisplayRepresentation = "Dish" + static let defaultQuery = DishQuery() + + var id: String + @Property(title: "Name") + var name: String + var subtitle: String? + var synonyms: [String] + + var displayRepresentation: DisplayRepresentation { + if let subtitle { + if #available(iOS 17.0, *) { + return DisplayRepresentation( + title: "\(name)", + subtitle: "\(subtitle)", + synonyms: synonyms.map { LocalizedStringResource(stringLiteral: $0) } + ) + } + return DisplayRepresentation(title: "\(name)", subtitle: "\(subtitle)") + } + + if #available(iOS 17.0, *) { + return DisplayRepresentation( + title: "\(name)", + synonyms: synonyms.map { LocalizedStringResource(stringLiteral: $0) } + ) + } + return DisplayRepresentation(title: "\(name)") + } + + init(record: AppIntentEntityRecord) { + self.id = record.id + self.subtitle = record.subtitle + self.synonyms = record.synonyms + self.name = record.title + } + + private var searchableTerms: [String] { + return [id, name] + synonyms + } + + func matches(_ searchText: String) -> Bool { + let normalizedSearch = normalizeDishText(searchText) + guard !normalizedSearch.isEmpty else { + return false + } + + return searchableTerms.map(normalizeDishText).contains { term in + normalizedSearch == term || normalizedSearch.contains(term) || term.contains(normalizedSearch) + } + } +} diff --git a/apps/native-component-list/app-intents/Entities/MailAccountEntity.swift b/apps/native-component-list/app-intents/Entities/MailAccountEntity.swift new file mode 100644 index 00000000000000..0688d8c20e3b2e --- /dev/null +++ b/apps/native-component-list/app-intents/Entities/MailAccountEntity.swift @@ -0,0 +1,31 @@ +import AppIntents +import Foundation + +/// The `.mail.draft` schema requires a non-optional account, so every draft needs one. This +/// example ships a single hardcoded account. A real mail app would publish its accounts to the +/// entity catalog from JavaScript, the same way the restaurant example publishes dishes. +@available(iOS 18.0, *) +@AppEntity(schema: .mail.account) +struct MailAccountEntity { + static let defaultQuery = MailAccountEntityQuery() + + static let `default` = MailAccountEntity( + id: "primary", + name: "Primary", + emailAddress: "me@example.com" + ) + + var id: String + var name: String + var emailAddress: String + + init(id: String, name: String, emailAddress: String) { + self.id = id + self.name = name + self.emailAddress = emailAddress + } + + var displayRepresentation: DisplayRepresentation { + return DisplayRepresentation(title: "\(name)", subtitle: "\(emailAddress)") + } +} diff --git a/apps/native-component-list/app-intents/Entities/MailDraftEntity.swift b/apps/native-component-list/app-intents/Entities/MailDraftEntity.swift new file mode 100644 index 00000000000000..9f071fd85e86fb --- /dev/null +++ b/apps/native-component-list/app-intents/Entities/MailDraftEntity.swift @@ -0,0 +1,129 @@ +import AppIntents +internal import ExpoAppIntents +import Foundation + +@available(iOS 18.0, *) +@AppEntity(schema: .mail.draft) +struct MailDraftEntity { + static let defaultQuery = MailDraftEntityQuery() + + var id: String + var to: [IntentPerson] + var cc: [IntentPerson] + var bcc: [IntentPerson] + var subject: String? + var body: AttributedString? + var attachments: [IntentFile] + var account: MailAccountEntity + + init( + id: String, + to: [IntentPerson], + cc: [IntentPerson], + bcc: [IntentPerson], + subject: String?, + body: AttributedString?, + attachments: [IntentFile], + account: MailAccountEntity + ) { + self.id = id + self.to = to + self.cc = cc + self.bcc = bcc + self.subject = subject + self.body = body + self.attachments = attachments + self.account = account + } + + /// Builds a draft from a catalog record published by JavaScript with + /// `setEntityCatalogAsync('mailDraft', drafts)`. The record's title carries the subject and its + /// subtitle carries the body, so Siri can resolve a draft the user names out loud. + init(record: AppIntentEntityRecord) { + self.init( + id: record.id, + to: [], + cc: [], + bcc: [], + subject: record.title.isEmpty ? nil : record.title, + body: record.subtitle.map(AttributedString.init), + attachments: [], + account: MailAccountEntity.default + ) + } + + var displayRepresentation: DisplayRepresentation { + return DisplayRepresentation(title: "\(displaySubject)", subtitle: "\(bodyPreview)") + } + + var displaySubject: String { + guard let subject = subject?.trimmingCharacters(in: .whitespacesAndNewlines), !subject.isEmpty + else { + return "No subject" + } + return subject + } + + var bodyText: String { + guard let body else { + return "" + } + return String(body.characters) + } + + /// A flat, JS-friendly recipient list. `IntentPerson` models a name and a handle rather than a + /// plain string, so it is projected to an address (or a display name when no address is known) + /// before it crosses into JavaScript. + /// + /// Each address stays a separate element. A display name formatted as "Doe, John" contains a comma + /// of its own, so joining the addresses into one string would let JavaScript read it as two + /// recipients. + var recipientAddresses: [String] { + return (to + cc + bcc).compactMap(Self.address(of:)) + } + + /// The same addresses as one string, for the places that want a single line instead of an array: a + /// summary label, or one string to match a search term against. + /// + /// Nothing in this example reads it yet. `displayRepresentation` shows the subject and a body + /// preview, and `MailDraftEntityQuery` searches those two, because a draft built from a catalog + /// record has no recipients - `init(record:)` above leaves `to`, `cc` and `bcc` empty. Add + /// recipients to the records your app publishes, then search this too. + var recipientList: String { + return recipientAddresses.joined(separator: ", ") + } + + private static func address(of person: IntentPerson) -> String? { + if let handle = person.handle { + switch handle.value { + case .emailAddress(let emailAddress): + return emailAddress + case .phoneNumber(let phoneNumber): + return phoneNumber + case .applicationDefined(let value): + return value + @unknown default: + break + } + } + + switch person.name { + case .displayName(let displayName): + return displayName + case .components(let components): + return PersonNameComponentsFormatter().string(from: components) + case .unknown: + return nil + @unknown default: + return nil + } + } + + private var bodyPreview: String { + let preview = bodyText.trimmingCharacters(in: .whitespacesAndNewlines) + guard preview.count > 80 else { + return preview + } + return String(preview.prefix(80)).trimmingCharacters(in: .whitespacesAndNewlines) + "..." + } +} diff --git a/apps/native-component-list/app-intents/IncreaseCounterIntent.swift b/apps/native-component-list/app-intents/IncreaseCounterIntent.swift new file mode 100644 index 00000000000000..9dda5ff98be4be --- /dev/null +++ b/apps/native-component-list/app-intents/IncreaseCounterIntent.swift @@ -0,0 +1,14 @@ +import AppIntents +internal import ExpoAppIntents + +/// The simplest shape of an App Intent: open the app and hand the invocation to JavaScript. +struct IncreaseCounterIntent: AppIntent { + static let title: LocalizedStringResource = "Increase Counter" + static let openAppWhenRun: Bool = true + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + await AppIntentDispatcher.shared.dispatch(name: "increaseCounter") + return .result(dialog: "Counter increased.") + } +} diff --git a/apps/native-component-list/app-intents/OrderFoodIntent.swift b/apps/native-component-list/app-intents/OrderFoodIntent.swift new file mode 100644 index 00000000000000..18688730bed093 --- /dev/null +++ b/apps/native-component-list/app-intents/OrderFoodIntent.swift @@ -0,0 +1,31 @@ +import AppIntents +internal import ExpoAppIntents + +/// A phrase-based shortcut that orders a dish. +/// +/// The required dish parameter lets Siri either resolve the dish from the launch phrase or ask +/// a follow-up question when the user only says "Place an order". +struct OrderFoodIntent: AppIntent { + static let title: LocalizedStringResource = "Order Food" + static let openAppWhenRun: Bool = true + + @Parameter(title: "Dish", requestValueDialog: "What would you like to order?") + var dish: DishEntity + + static var parameterSummary: some ParameterSummary { + Summary("Order \(\.$dish)") + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + await AppIntentDispatcher.shared.dispatch( + name: "orderFood", + params: [ + "dishId": .string(dish.id), + "dishName": .string(dish.name), + ] + ) + + return .result(dialog: "Ordering \(dish.name).") + } +} diff --git a/apps/native-component-list/app-intents/Queries/DishQuery.swift b/apps/native-component-list/app-intents/Queries/DishQuery.swift new file mode 100644 index 00000000000000..128deb8f901dd9 --- /dev/null +++ b/apps/native-component-list/app-intents/Queries/DishQuery.swift @@ -0,0 +1,32 @@ +import AppIntents +internal import ExpoAppIntents + +/// Resolves DishEntity values from the catalog your JavaScript code publishes. +/// +/// The catalog starts out empty, and every method here reads from it, so this query returns nothing +/// until your app has run this at least once: +/// +/// ```ts +/// import { setEntityCatalogAsync } from 'expo-app-intents'; +/// +/// await setEntityCatalogAsync('dish', [{ id: 'margherita', title: 'Margherita' }]); +/// ``` +/// +/// `OrderFoodIntent.dish` is a required parameter, so with an empty catalog Siri has nothing to offer +/// and the shortcut cannot run. The catalog is stored natively and survives restarts, so publish it +/// once at startup and again whenever your menu changes. +struct DishQuery: EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [DishEntity] { + return try await AppIntentEntityStore.shared.entities(ofKind: "dish", matching: identifiers) + .map(DishEntity.init(record:)) + } + + func suggestedEntities() async throws -> [DishEntity] { + return try await AppIntentEntityStore.shared.entities(ofKind: "dish") + .map(DishEntity.init(record:)) + } + + func entities(matching string: String) async throws -> [DishEntity] { + return try await suggestedEntities().filter { $0.matches(string) } + } +} diff --git a/apps/native-component-list/app-intents/Queries/MailAccountEntityQuery.swift b/apps/native-component-list/app-intents/Queries/MailAccountEntityQuery.swift new file mode 100644 index 00000000000000..a935ccaa434aa2 --- /dev/null +++ b/apps/native-component-list/app-intents/Queries/MailAccountEntityQuery.swift @@ -0,0 +1,26 @@ +import AppIntents + +/// Schema entities must be resolvable by the system, which means the default query has to be an +/// `EntityStringQuery` (or the entity has to be indexed). A plain `EntityQuery` is rejected by the +/// App Intents metadata extractor at build time. +@available(iOS 18.0, *) +struct MailAccountEntityQuery: EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [MailAccountEntity] { + return try await suggestedEntities().filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [MailAccountEntity] { + return [MailAccountEntity.default] + } + + func entities(matching string: String) async throws -> [MailAccountEntity] { + let query = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { + return try await suggestedEntities() + } + + return try await suggestedEntities().filter { account in + account.name.lowercased().contains(query) || account.emailAddress.lowercased().contains(query) + } + } +} diff --git a/apps/native-component-list/app-intents/Queries/MailDraftEntityQuery.swift b/apps/native-component-list/app-intents/Queries/MailDraftEntityQuery.swift new file mode 100644 index 00000000000000..eaa114090c9581 --- /dev/null +++ b/apps/native-component-list/app-intents/Queries/MailDraftEntityQuery.swift @@ -0,0 +1,29 @@ +import AppIntents +internal import ExpoAppIntents + +@available(iOS 18.0, *) +struct MailDraftEntityQuery: EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [MailDraftEntity] { + return try await AppIntentEntityStore.shared.entities(ofKind: "mailDraft", matching: identifiers) + .map(MailDraftEntity.init(record:)) + } + + func suggestedEntities() async throws -> [MailDraftEntity] { + return try await AppIntentEntityStore.shared.entities(ofKind: "mailDraft") + .map(MailDraftEntity.init(record:)) + } + + func entities(matching string: String) async throws -> [MailDraftEntity] { + let query = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { + return try await suggestedEntities() + } + + return try await suggestedEntities().filter { draft in + [draft.displaySubject, draft.bodyText] + .joined(separator: " ") + .lowercased() + .contains(query) + } + } +} diff --git a/apps/native-component-list/app.json b/apps/native-component-list/app.json index cafe0eed0eca60..086e47919bd4b2 100644 --- a/apps/native-component-list/app.json +++ b/apps/native-component-list/app.json @@ -69,7 +69,8 @@ { "image": "./assets/icons/loadingIcon.png" } - ] + ], + "expo-app-intents" ], "extra": { "eas": { @@ -79,7 +80,7 @@ "experiments": { "noxcturnalTransformWorker": true, "inlineModules": { - "watchedDirectories": ["src/screens/inlineModules/inlineModulesExamples/"] + "watchedDirectories": ["src/screens/inlineModules/inlineModulesExamples/", "app-intents"] } } } diff --git a/apps/native-component-list/package.json b/apps/native-component-list/package.json index d46df30d64f3ac..8ce77a0a7cca98 100644 --- a/apps/native-component-list/package.json +++ b/apps/native-component-list/package.json @@ -44,6 +44,7 @@ "expo": "workspace:*", "expo-2d-context": "^0.0.4", "expo-age-range": "workspace:*", + "expo-app-intents": "workspace:*", "expo-app-metrics": "workspace:*", "expo-apple-authentication": "workspace:*", "expo-application": "workspace:*", diff --git a/apps/native-component-list/src/app/_layout.tsx b/apps/native-component-list/src/app/_layout.tsx index 1472dcd75fc7a9..562cdf756a6250 100644 --- a/apps/native-component-list/src/app/_layout.tsx +++ b/apps/native-component-list/src/app/_layout.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { Platform, StatusBar } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { AppIntentsNavigationHandler } from '../screens/AppIntents/AppIntentsNavigationHandler'; import { getSearchScreenOptions } from '../screens/SearchScreen'; import loadAssetsAsync from '../utilities/loadAssetsAsync'; @@ -23,6 +24,8 @@ function useSplashScreen(loadingFunction: () => Promise) { console.warn(e); } finally { setLoadingComplete(true); + // The splash screen comes down here and nowhere else, so no feature can keep the app + // from starting. await SplashScreen.hide(); } } @@ -56,6 +59,7 @@ function RootLayout() { + ); } diff --git a/apps/native-component-list/src/navigation/apiScreens.ts b/apps/native-component-list/src/navigation/apiScreens.ts index b75df4dc83df90..9a01c89d2dae57 100644 --- a/apps/native-component-list/src/navigation/apiScreens.ts +++ b/apps/native-component-list/src/navigation/apiScreens.ts @@ -1,5 +1,6 @@ import { isRunningInExpoGo } from 'expo'; +import { AppIntentsScreens } from '../screens/AppIntents/AppIntentsScreen'; import { AudioScreens } from '../screens/Audio/AudioScreen'; import { BlobScreens } from '../screens/Blob/BlobScreen'; import { CalendarNextScreens } from '../screens/Calendar@Next/CalendarNextScreens'; @@ -111,6 +112,14 @@ export const ScreensList: ScreenConfig[] = [ name: 'AppMetrics', options: { title: 'App Metrics' }, }, + { + getComponent() { + return optionalRequire(() => require('../screens/AppIntents/AppIntentsScreen')); + }, + name: 'AppIntents', + route: 'app-intents', + options: { title: 'App Intents' }, + }, { getComponent() { return optionalRequire(() => require('../screens/AsyncStorageScreen')); @@ -501,6 +510,7 @@ export const ScreensList: ScreenConfig[] = [ export const Screens: ScreenConfig[] = [ ...ScreensList, + ...AppIntentsScreens, ...ModulesCoreScreens, ...MediaLibraryScreens, ...AudioScreens, diff --git a/apps/native-component-list/src/screens/AppIntents/AppIntentCounterScreen.tsx b/apps/native-component-list/src/screens/AppIntents/AppIntentCounterScreen.tsx new file mode 100644 index 00000000000000..e2dcd631ebbbbd --- /dev/null +++ b/apps/native-component-list/src/screens/AppIntents/AppIntentCounterScreen.tsx @@ -0,0 +1,91 @@ +import { useTheme } from 'ThemeProvider'; +import { useRoute } from 'expo-router'; +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { BodyText } from '../../components/BodyText'; +import Button from '../../components/Button'; +import { ScrollPage, Section } from '../../components/Page'; +import { AppIntentExitButton } from './AppIntentExitButton'; +import { getCounterState, resetCounterState, type AppIntentCounterState } from './AppIntentsStore'; +import { useAppIntentState } from './useAppIntentState'; + +const initialCounterState: AppIntentCounterState = { + count: 0, + countedInvocationIds: [], +}; + +function formatDate(timestamp?: number): string { + return timestamp ? new Date(timestamp).toLocaleString() : 'Never'; +} + +export default function AppIntentCounterScreen() { + const route = useRoute(); + const { theme } = useTheme(); + const counterState = useAppIntentState(getCounterState, initialCounterState); + const openedBySiri = route.params?.source === 'siri'; + + return ( + +
+ + {counterState.count} + + {openedBySiri + ? 'Opened after the Increase Counter intent ran.' + : 'Opened manually from the API list.'} + + +
+ +
+ Last increment: {formatDate(counterState.lastIncrementedAt)} + Invocation id: {counterState.lastInvocationId ?? 'None'} +
+ +
+ + +
+
+ ); +} + +AppIntentCounterScreen.navigationOptions = { + title: 'App Intent Counter', +}; + +const styles = StyleSheet.create({ + hero: { + borderRadius: 6, + borderWidth: StyleSheet.hairlineWidth, + padding: 16, + gap: 8, + }, + count: { + fontSize: 44, + fontWeight: '700', + }, + controls: { + gap: 10, + }, +}); diff --git a/apps/native-component-list/src/screens/AppIntents/AppIntentExitButton.tsx b/apps/native-component-list/src/screens/AppIntents/AppIntentExitButton.tsx new file mode 100644 index 00000000000000..bf32702ab6cbed --- /dev/null +++ b/apps/native-component-list/src/screens/AppIntents/AppIntentExitButton.tsx @@ -0,0 +1,17 @@ +import { useRouter } from 'expo-router'; + +import Button from '../../components/Button'; + +export function AppIntentExitButton() { + const router = useRouter(); + + const onPress = () => { + if (router.canGoBack()) { + router.back(); + return; + } + router.replace('/apis'); + }; + + return