Engineering

Mastering App Intents: Querying Made Easy

🔍 Uncover the secrets of effective data retrieval in iOS development

App Intents are one of the most powerful features on iOS, but there is no clear pattern for sharing data and logic between your app and your App Intent. In this post, we will be building a simple to-do list to show how to share data between your app’s existing data model and an App Intent. We will be focusing on a single item: marking a task in the list as complete. The GitHub repository of this project can be found here if you would like to code along!

Getting started

Let’s start by creating a new file in the Xcode project called CompleteTaskIntent. In this file, create a struct called CompleteTaskIntent, and conform it to the AppIntents protocol. For a more in-depth look into the components for setting up App Intents, you can visit my previous post.

Note: To conform to the AppIntents protocol, you need to add a title and the perform method.

Your file should look similar to this:

    struct CompleteTaskIntent: AppIntent {
        
        static var title: LocalizedStringResource = "Complete Task"
        
        func perform() async throws -> some IntentResult {
            return .result()
        }
    }

If you run the above logic in Xcode, open the Shortcuts app, and tap the + button to add a new shortcut, you should see this intent displayed under the “Apps” section of actions to add to your shortcut.

Adding parameters

Now, let’s add our first property: the date.

    struct CompleteTaskIntent: AppIntent {
    
        @Parameter(title: "Date")
        private var date: Date
        
        static var title: LocalizedStringResource = "Complete Task"
        
        func perform() async throws -> some IntentResult {
            return .result()
        }
    }

You may notice that date is a non-optional parameter. In this context, we are indicating to the system that this is a required parameter.

Now, lets add our second parameter: the task. Create a new parameter for your task like we did before — this time, the type of parameter is TodoTask.

    struct CompleteTaskIntent: AppIntent {
    
        @Parameter(title: "Date")
        private var date: Date
        
        @Parameter(title: "Task")
        private var task: TodoTask
    
        static var title: LocalizedStringResource = "Complete Task"
        
        func perform() async throws -> some IntentResult {
            return .result()
        }
    }

This code doesn’t compile!

The system is not aware of our app’s data model, so this code does not compile. To expose this “custom data type” to the system, we must put it in a form the system can recognize. In the App Intents framework, the way we do this is to create a new model conforming to the AppEntity.

An interface for exposing a custom type or app-specific concept to system services such as Siri and the Shortcuts app. – Apple

Once we’ve conformed our model to this protocol, the code will compile.

Apple recommends creating another structure representing the parts of your data model that your intent will supply the information to.

For this exercise, we will extend our data model to conform to the AppEntity protocol. You can add this code to a new file, or to the file containing the data model.

    extension TodoTask: AppEntity {
        
        struct TaskQuery: EntityQuery {
            func entities(for: [Self.Entity.ID]) async throws -> [Self.Entity] {
                return []
            }
            
            func suggestedEntities() async throws -> Self.Result {
                return []
            }
        }
        
        var displayRepresentation: DisplayRepresentation {
            return .init(stringLiteral: "\(title)")
        }
        
        static var defaultQuery: TaskQuery = TaskQuery()
        
        static var typeDisplayRepresentation: TypeDisplayRepresentation = .init(name: "Task")
    }

To conform to the AppEntity protocol, we need to implement three properties:

  1. displayRepresentation: DisplayRepresentation: This property defines how the custom type will be displayed in the UI. You can specify the title, subtitle, synomyms and image.

  2. defaultQuery: EntityQuery: This provides the default query to be used when querying and retrieving entities with Shortcuts and Siri. When trying to resolve a parameter, the system will perform these queries to provide entities to be used by the UI.

  3. typeDisplayRepresentation: TypeDisplayRepresentation: A short, localized, human-readable name for the type.

In the code above, we are creating a new TaskQuery, which has two methods that both return an empty array.

Creating a query

Let’s take a closer look at query creation. According to Apple’s documentation, when the system needs to retrieve one or more specific instances of an app entity, it asks you to provide a relevant query type — these queries are used at parameter resolution times. When we create our queries, they must conform to the EntityQuery protocol, which has two required methods illustrated below:

    struct TaskQuery: EntityQuery {
    
        func entities(for identifiers: [Entity.ID]) async throws -> [TodoTask] {
             return []
        }
            
        func suggestedEntities() async throws -> [TodoTask] {
             return []
        }
    }

The required methods of EntityQuery

  1. entities(for identifiers: [Entity.ID]) async throws -> [TodoTask]: This method retrieves entities by their identifiers. Entities that do not match the identifiers will be skipped.

  2. func suggestedEntities() async throws -> [TodoTask]: According to Apple, this method returns the initial results shown when a list of options backed by this query is presented.

Note: When requiring the user to choose one item from a collection of entities, Siri will repeatedly speak aloud asking the user to pick a selection without providing a list of options. In these situations, you should disambiguate between the items in your collection using requestDisambiguation(among: \[Entity\]). This code will work as expected in the Shortcuts app.

Let’s continue — the aim of this intent is to provide a list of tasks for the user to choose from. Lets implement our query, starting with the suggestedEntities method.

Currently, our query object has no notion of the selected date. In order to bridge this gap, we have the handy property wrapper @IntentParameterDependency, which allows us to obtain the intent and its properties. It can be used on objects which conform to DynamicOptionsProvider.

Add the code below to the TaskQuery section above, and build:

    struct TaskQuery: EntityQuery {
        
        @IntentParameterDependency<CompleteTaskIntent>(
            \.$date
        )
        var completeTaskIntent
        
        private let taskManager: TaskManager = TaskManager.shared
        private let calendar = Calendar.autoupdatingCurrent
        
        func entities(for identifiers: [UUID]) async throws -> [TodoTask] {
            return try await suggestedEntities().filter { task in
                return identifiers.contains(task.id)
            }
        }
        
        func suggestedEntities() async throws -> [TodoTask] {
            guard let selectedDate = completeTaskIntent?.date else {
                return []
            }
            
            let startDate: Date = calendar.startOfDay(for: selectedDate)
            let endDate = calendar.date(byAdding: .day, value: 1, to: startDate)!
            
            let foundTasks: [TodoTask] = await taskManager.fetchTasks()
            let filteredTasks: [TodoTask] = foundTasks.filter { task in
                task.createDate >= startDate &&  task.createDate < endDate && !task.isComplete
            }
            return filteredTasks
        }
    }

For our querying logic within the suggestedEntites() method above, we ensure the selected date is present. If it isn’t present, we return an empty array. We then get the start of the selected date, and create the end date by adding one day to that. With this logic, we are trying to set up the window that queried tasks with fall within. Then, we also make sure the tasks returned have not already been completed, ensuring we have the most accurate data presented to the user. We then update the entities(for identifiers: [UUID]) method. In this method, we use the suggestedEntites to return the tasks that match the identifiers provided by the system.

Adding a task

At this point in the process, we haven’t created any tasks, so when we run the app or try to execute the intent, we won’t see any options provided.

Let’s change that! Run the app and tap on the plus button on the main screen. A modal view will present itself — add a task there. You should see the task presented on the list once added.

Now that we’ve added a task, lets execute our intent. Open the Shortcuts app and follow the video below to execute the intent:

You may have noticed that tapping the option in the presented menu does not mark the task as complete in the app. If you take a look in the CompleteTaskIntent.swift file within the perform method, you’ll see we haven’t added any logic to be executed after the parameter resolution phase has taken place.

    func perform() async throws -> some IntentResult {
         return .result()
    }

In the Tasks folder, there is a file called TaskManager that has the ability to add a task to the database. Add this as a property to this object. We will create a new instance of this object within our intent — in the context of Shortcuts and Siri, there is no way for us to inject it as a dependency. (In other contexts, such as within the widget or in the main application, we may be able to inject the TaskManager as a dependency.) The intent file should now look like this.

    struct CompleteTaskIntent: AppIntent {
    
        @Parameter(title: "Date")
        var date: Date
        
        @Parameter(title: "Task")
        private var task: TodoTask
    
        static var title: LocalizedStringResource = "Complete Task"
        
        private var taskManager = TaskManager.shared
        
        @MainActor
        func perform() async throws -> some IntentResult {
            taskManager.markTaskAsComplete(task: task)
            return .result()
        }
    }

Now, when you execute the shortcut and select a task once you return to the app, that task will be completed. To make our user experience a little nicer, lets add a view to confirm that the task has been marked completed.

To achieve this, lets create our confirmation view by adding this code to the intent file:

    private var successConfirmationView: some View {
            VStack {
                Image(systemName: "checkmark.circle")
                    .foregroundStyle(.green)
                    .imageScale(.large)
                    .font(.largeTitle)
                Text("Task Completed")
            }
        }

To ensure our intent doesn’t crash, we will update our perform method return type to include ShowsSnippetView:

             @MainActor
        func perform() async throws -> some ShowsSnippetView {
            taskManager.markTaskAsComplete(task: task)
            return .result(view: successConfirmationView)
        }

Now we can call the result method and pass the successConfirmationView as a parameter.

The app can now open once the task is executed. By default, the value of openAppWhenRun is false — if you’d like to open the app after the task has been executed, update this value to true.

Wrapping up

In this blog post, we’ve explored the intricacies of creating an App Intent for our simple to-do list application. Starting with the basics, we delved into how to define and handle parameters such as the date and task selection, and then integrated them into our app’s data model. Our journey included conforming to the AppIntents protocol, dealing with the nuances of custom data types, and understanding the importance of entity queries in the AppIntents framework.

As we have seen, integrating App Intents requires a thoughtful approach to app architecture and a deep understanding of both the app’s data model and the AppIntents framework. However, the payoff is a more streamlined and efficient user experience.

For those who are looking to further explore this topic or apply these concepts in their own projects, I encourage you to experiment with different types of intents and explore how they can be tailored to fit the unique requirements of your app. The possibilities are endless!