A Flutter plugin for integrating Apple App Intents with your iOS applications. This plugin enables your Flutter app to work seamlessly with Siri, Shortcuts, Spotlight, and other system experiences on iOS 16.0 and later.
📖 Complete Documentation - Visit our comprehensive documentation website with tutorials, examples, and API reference.
Add this to your package’s pubspec.yaml file:
dependencies:
flutter_app_intents: ^0.7.0
For iOS developers who want to use the native Swift components directly, this package also supports Swift Package Manager:
// In Package.swift
dependencies: [
.package(url: "https://github.com/cbonello/flutter_app_intents", from: "0.7.0")
]
Or add via Xcode: File → Add Package Dependencies → https://github.com/cbonello/flutter_app_intents
Note: SPM support is provided for advanced use cases. Most Flutter developers should use the standard plugin installation above. See SPM_README.md for detailed SPM integration instructions.
This plugin uses a hybrid approach combining:
iOS Shortcuts/Siri → Static Swift Intent → Flutter Plugin Bridge → Your Flutter Handler
The static Swift intents act as a bridge, calling your Flutter handlers when executed.
Note: This example shows the core logic, but a complete implementation requires a corresponding static intent in your iOS app’s
AppDelegate.swift. See the iOS Configuration section for details.
Create a voice-controlled counter app in just a few steps:
// 1. Register your intent
final client = FlutterAppIntentsClient.instance;
final intent = AppIntentBuilder()
.identifier('increment_counter')
.title('Increment Counter')
.build();
await client.registerIntent(intent, (parameters) async {
// Your business logic here
incrementCounter();
return AppIntentResult.successful(value: 'Counter incremented!');
});
// 2. Add static intent to iOS (AppDelegate.swift)
import AppIntents
struct IncrementCounterIntent: AppIntent {
static var title: LocalizedStringResource = "Increment Counter"
func perform() async throws -> some IntentResult {
await FlutterAppIntentsPlugin.shared.handleIntent("increment_counter", [:])
return .result()
}
}
Result: Say “Hey Siri, increment counter” and your Flutter function runs! 🎉
📖 New to App Intents? Check out our Step-by-Step Tutorial for a complete walkthrough from
flutter createto working Siri integration!
import 'package:flutter_app_intents/flutter_app_intents.dart';
⚠️ First, add static App Intents to your iOS AppDelegate.swift (see iOS Configuration section below)
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Register intents during app initialization
_setupAppIntents();
return MaterialApp(
title: 'My App',
home: MyHomePage(),
);
}
Future<void> _setupAppIntents() async {
final client = FlutterAppIntentsClient.instance;
// Create an intent using the builder
final incrementIntent = AppIntentBuilder()
.identifier('increment_counter')
.title('Increment Counter')
.description('Increments the counter by one')
.parameter(const AppIntentParameter(
name: 'amount',
title: 'Amount',
type: AppIntentParameterType.integer,
isOptional: true,
defaultValue: 1,
))
.build();
// Register with a handler
await client.registerIntent(incrementIntent, (parameters) async {
final amount = parameters['amount'] as int? ?? 1;
// Your business logic here
incrementCounter(amount);
return AppIntentResult.successful(
value: 'Counter incremented by $amount',
);
});
}
}
Future<AppIntentResult> handleIncrementIntent(Map<String, dynamic> parameters) async {
try {
final amount = parameters['amount'] as int? ?? 1;
// Perform your app's logic
final newValue = incrementCounter(amount);
// Donate the intent to help Siri learn
await FlutterAppIntentsClient.instance.donateIntent(
'increment_counter',
parameters,
);
return AppIntentResult.successful(
value: 'Counter is now $newValue',
);
} catch (e) {
return AppIntentResult.failed(
error: 'Failed to increment counter: $e',
);
}
}
Our plugin excels at handling app navigation through voice commands and shortcuts. Here’s how to implement navigation intents:
For navigation, use needsToContinueInApp: true to tell iOS to focus your app and OpensIntent return type in Swift:
iOS Implementation:
@available(iOS 16.0, *)
struct OpenProfileIntent: AppIntent {
static var title: LocalizedStringResource = "Open Profile"
static var description = IntentDescription("Open user profile page")
static var isDiscoverable = true
static var openAppWhenRun = true
@Parameter(title: "User ID")
var userId: String?
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "open_profile",
parameters: ["userId": userId ?? "current"]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "Profile opened"
return .result(value: value) // This opens/focuses the app
} else {
let errorMessage = result["error"] as? String ?? "Failed to open profile"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
Flutter Handler:
Future<AppIntentResult> _handleOpenProfileIntent(
Map<String, dynamic> parameters,
) async {
final userId = parameters['userId'] as String? ?? 'current';
// Navigate to the target page
Navigator.of(context).pushNamed('/profile', arguments: {'userId': userId});
return AppIntentResult.successful(
value: 'Opening profile for user $userId',
needsToContinueInApp: true, // Critical: focuses the app
);
}
// Navigate to specific content with parameters
Future<AppIntentResult> _handleOpenChatIntent(Map<String, dynamic> parameters) async {
final contactName = parameters['contactName'] as String;
Navigator.of(context).pushNamed('/chat', arguments: {
'contactName': contactName,
'openedViaIntent': true,
});
return AppIntentResult.successful(
value: 'Opening chat with $contactName',
needsToContinueInApp: true,
);
}
// Handle search queries with navigation
Future<AppIntentResult> _handleSearchIntent(Map<String, dynamic> parameters) async {
final query = parameters['query'] as String;
Navigator.of(context).pushNamed('/search', arguments: {'query': query});
return AppIntentResult.successful(
value: 'Searching for "$query"',
needsToContinueInApp: true,
);
}
// Navigate to specific settings pages
Future<AppIntentResult> _handleOpenSettingsIntent(Map<String, dynamic> parameters) async {
final section = parameters['section'] as String? ?? 'general';
Navigator.of(context).pushNamed('/settings/$section');
return AppIntentResult.successful(
value: 'Opening $section settings',
needsToContinueInApp: true,
);
}
If you’re using GoRouter, the pattern is similar:
Future<AppIntentResult> _handleNavigationIntent(Map<String, dynamic> parameters) async {
final route = parameters['route'] as String;
// Use GoRouter for navigation
context.go(route);
return AppIntentResult.successful(
value: 'Navigating to $route',
needsToContinueInApp: true,
);
}
Add navigation shortcuts to your AppShortcutsProvider:
@available(iOS 16.0, *)
struct AppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
return [
// Navigation shortcuts
AppShortcut(
intent: OpenProfileIntent(),
phrases: [
"Open my profile in ${applicationName}",
"Show profile using ${applicationName}",
"Go to profile with ${applicationName}"
]
),
AppShortcut(
intent: OpenChatIntent(),
phrases: [
"Chat with \\(.contactName) using ${applicationName}",
"Open chat with \\(.contactName) in ${applicationName}",
"Message \\(.contactName) with ${applicationName}"
]
)
]
}
}
| Intent Type | Return Type | Use Case | Example |
|---|---|---|---|
| Query | ReturnsValue<String> |
Get information only | “Get counter value”, “Check weather” |
| Action + App Opening | ReturnsValue<String> & OpensIntent |
Execute + show result | “Increment counter”, “Send message” |
| Navigation | ReturnsValue<String> & OpensIntent |
Navigate to pages | “Open profile”, “Show chat” |
The main client class for managing App Intents:
registerIntent(AppIntent intent, handler) - Register a single intent with handlerregisterIntents(Map<AppIntent, handler>) - Register multiple intentsunregisterIntent(String identifier) - Remove an intentgetRegisteredIntents() - Get all registered intentsupdateShortcuts() - Refresh app shortcutsdonateIntent(String identifier, parameters) - Intent donation for Siri learning and predictionsRepresents an App Intent configuration:
const AppIntent({
required String identifier, // Unique ID
required String title, // Display name
required String description, // What it does
List<AppIntentParameter> parameters = const [],
bool isEligibleForSearch = true,
bool isEligibleForPrediction = true,
AuthenticationPolicy authenticationPolicy = AuthenticationPolicy.none,
});
Defines parameters that can be passed to intents:
const AppIntentParameter({
required String name, // Parameter name
required String title, // Display title
required AppIntentParameterType type,
String? description,
bool isOptional = false,
dynamic defaultValue,
});
Result returned from intent execution:
// Successful result
AppIntentResult.successful(
value: 'Operation completed',
needsToContinueInApp: false,
);
// Failed result
AppIntentResult.failed(
error: 'Something went wrong',
);
Fluent API for creating intents:
final intent = AppIntentBuilder()
.identifier('my_intent')
.title('My Intent')
.description('Does something useful')
.parameter(myParameter)
.eligibleForSearch(true)
.authenticationPolicy(AuthenticationPolicy.requiresAuthentication)
.build();
The plugin provides advanced intent donation capabilities to help Siri learn user patterns and provide better predictions.
// Donate intent for Siri learning
await FlutterAppIntentsClient.instance.donateIntent(
'my_intent',
{'param': 'value'},
);
Donate intents after successful execution to help Siri learn user patterns:
// Execute the intent action
final result = await performAction();
// Donate if successful
if (result.isSuccess) {
await FlutterAppIntentsClient.instance.donateIntent(
'my_intent',
parameters,
);
}
When to donate:
When not to donate:
⚠️ Important: iOS App Intents framework requires static intent declarations in your main app target, not just dynamic registration from the plugin.
Add this code to your iOS app’s AppDelegate.swift:
import Flutter
import UIKit
import AppIntents
import flutter_app_intents
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
// Static App Intents that bridge to Flutter handlers
@available(iOS 16.0, *)
struct MyCounterIntent: AppIntent {
static var title: LocalizedStringResource = "Increment Counter"
static var description = IntentDescription("Increment the counter by one")
static var isDiscoverable = true
static var openAppWhenRun = true
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "increment_counter",
parameters: [:]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "Counter incremented"
return .result(value: value)
} else {
let errorMessage = result["error"] as? String ?? "Failed to increment counter"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
// Error handling for App Intents
enum AppIntentError: Error {
case executionFailed(String)
}
// AppShortcutsProvider for Siri/Shortcuts discovery
@available(iOS 16.0, *)
struct AppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
return [
AppShortcut(
intent: MyCounterIntent(),
phrases: [
"Increment counter with \(.applicationName)",
"Add one with \(.applicationName)",
"Count up using \(.applicationName)"
]
)
]
}
}
When defining phrases for your App Shortcuts, follow these best practices for optimal user experience and Siri recognition:
App Shortcuts phrases CANNOT be defined dynamically. This is a fundamental limitation of Apple’s App Intents framework:
// ❌ DOES NOT WORK - phrases must be static literals
phrases: [
"\(userDefinedPhrase) with \(.applicationName)", // Won't compile
dynamicPhraseVariable, // Won't compile
generatePhrase() // Won't compile
]
// ✅ WORKS - static phrases with dynamic parameters
phrases: [
"Send message to \(.contactName) with \(.applicationName)", // ✅ Parameter is dynamic
"Set timer for \(.duration) using \(.applicationName)", // ✅ Parameter is dynamic
"Play \(.songName) in \(.applicationName)" // ✅ Parameter is dynamic
]
Why phrases must be static:
Workarounds for dynamic content:
While Apple doesn’t publish exact hard limits, there are practical constraints on the number of phrases:
Recommended Limits:
// ✅ GOOD - Focused, natural variations (4 phrases)
AppShortcut(
intent: SendMessageIntent(),
phrases: [
"Send message to \(.contactName) with \(.applicationName)",
"Text \(.contactName) using \(.applicationName)",
"Message \(.contactName) in \(.applicationName)",
"Write to \(.contactName) with \(.applicationName)"
]
)
// ❌ EXCESSIVE - Too many similar phrases (impacts performance)
AppShortcut(
intent: SendMessageIntent(),
phrases: [
"Send message to \(.contactName) with \(.applicationName)",
"Send a message to \(.contactName) with \(.applicationName)",
"Send text message to \(.contactName) with \(.applicationName)",
"Send a text message to \(.contactName) with \(.applicationName)",
// ... 15+ more variations
]
)
Performance Impact:
Best Strategy:
✅ Recommended:
phrases: [
"Increment counter with \(.applicationName)",
"Add one using \(.applicationName)",
"Count up in \(.applicationName)"
]
❌ Avoid:
phrases: [
"Increment counter", // Too generic, conflicts with other apps
"Add one" // Ambiguous without context
]
Choose prepositions that sound natural in conversation:
Offer 3-5 phrase variations to accommodate different user preferences:
phrases: [
"Increment counter with \(.applicationName)", // Formal
"Add one using \(.applicationName)", // Casual
"Count up in \(.applicationName)", // Alternative verb
"Bump counter with \(.applicationName)", // Colloquial
"Increase count using \(.applicationName)" // Descriptive
]
App Name at Beginning (less common but valid):
phrases: [
"Use \(.applicationName) to increment counter",
"Tell \(.applicationName) to reset timer"
]
Action-First Pattern (most natural):
phrases: [
"Start workout with \(.applicationName)",
"Send message using \(.applicationName)",
"Check weather in \(.applicationName)"
]
Action Intents:
"[Action] [Object] with \(.applicationName)"
"[Verb] [Noun] using \(.applicationName)"
Query Intents:
"Get [Data] from \(.applicationName)"
"Check [Status] in \(.applicationName)"
"What's [Information] using \(.applicationName)"
Navigation Intents:
"Open [Page] in \(.applicationName)"
"Go to [Section] using \(.applicationName)"
"Show [Content] with \(.applicationName)"
Add these permissions and configuration to your iOS Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>This app uses microphone for Siri integration</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>This app uses speech recognition for Siri integration</string>
<!-- App Intents Configuration -->
<key>NSAppIntentsConfiguration</key>
<dict>
<key>NSAppIntentsPackage</key>
<string>your_app_bundle_id</string>
</dict>
<key>NSAppIntentsMetadata</key>
<dict>
<key>NSAppIntentsSupported</key>
<true/>
</dict>
Ensure your iOS deployment target is set to 16.0 or later:
# ios/Podfile
platform :ios, '16.0'
The following parameter types are supported:
AppIntentParameterType.string - Text inputAppIntentParameterType.integer - Whole numbersAppIntentParameterType.boolean - True/false valuesAppIntentParameterType.double - Decimal numbersAppIntentParameterType.date - Date/time valuesAppIntentParameterType.url - Web URLsAppIntentParameterType.file - File referencesAppIntentParameterType.entity - Custom app-specific typesControl when intents can be executed:
AuthenticationPolicy.none - No authentication requiredAuthenticationPolicy.requiresAuthentication - User must be authenticatedAuthenticationPolicy.requiresUnlockedDevice - Device must be unlockedneedsToContinueInApp: true for long operations - Return immediately and continue processing in your app:
Future<AppIntentResult> _handleLongOperation(Map<String, dynamic> parameters) async {
// Start background work but return immediately
_startBackgroundWork();
return AppIntentResult.successful(
value: 'Operation started, opening app for progress...',
needsToContinueInApp: true, // Opens your app where you can show progress
);
}
static var openAppWhenRun = true in Swift intents that should open the app& OpensIntent to the return type for intents that open the appneedsToContinueInApp: true in Flutter results for visual feedbackneedsToContinueInApp: true for navigation intentsstatic var openAppWhenRun = true to force app openingReturnsValue<String> & OpensIntent return type in SwiftOur Step-by-Step Tutorial walks you through building a complete counter app with Siri integration from scratch.
Check out the example apps for complete implementations showing different App Intent patterns:
ProvidesDialog for Siri speech outputThis plugin only works on iOS 16.0+. Make sure you’re testing on a compatible device or simulator.
Most Common Issues: Missing static App Intents or disabled Siri integration
AppDelegate.swift (see iOS Configuration above)isEligibleForPrediction is trueArchitecture Note: iOS App Intents framework requires static intent declarations at compile time for Siri/Shortcuts discovery. Dynamic registration from Flutter plugins alone is not sufficient.
needsToContinueInApp: true in Flutter resultOpensIntent return type in Swift intentIf intent donation fails, check:
For deeper understanding of the underlying iOS concepts, refer to these official Apple resources:
This package is an independent Flutter plugin for Apple App Intents integration.
This project is licensed under the MIT License - see the LICENSE file for details.