Migrating Widget Configurations with Parent Parameters to use AppIntent

In iOS 17 (announced in June 2023), it became possible to configure widgets using the new "App Intents" system, instead of the legacy "INIntents" system.

The legacy .intentsdefinition file had become extremely unwieldy to use, so this is a great improvement (especially when supporting as many languages as Streaks does).

However, there was one particular feature we're using which has made this conversion difficult: parent parameters.

For example, take the Streaks stats widget. This is a single widget that allows the user to choose different charts to show:

Configuration options for the Stats widget in Streaks.

Once configured, it would shows the associated chart:

The Stats widget when "Mini Charts" is chosen.

Certain charts also included a "time of day" parameter, but using the parent parameter option, the time of day selection would only show when relevant. This is represented in the .intentsdefinition file as follows:

The structure of the Stats configuration screen. timePeriodTimeOfDay is only shown if type is set to timeOfDay.

In order to make this function in the same way when using App Intents, we used the parameterSummary property:

struct StatsWidgetConfigurationAppIntent: AppIntent, WidgetConfigurationIntent {

    // ...
    // @Parameter var type, timePeriod, taskType, etc..
    // ...
    
    static var parameterSummary: some ParameterSummary {
        When(\StatsWidgetConfigurationAppIntent.$type, .oneOf, [.timeOfDay, .daysOfWeek, .completionSuccess]) {
            Summary("\(\.$type)") {
                \.$timePeriod
                \.$taskType
                \.$showTitle
                \.$theme
            }
        } otherwise : {
            Summary("\(\.$type)") {
                \.$taskType
                \.$showTitle
                \.$theme
            }
        }
    }
}

This reads as follows:

If the user selects "time of day", "day of week", or "completion success", show list of options including the "time period" option. For all other chart types, don't show "time period".

This all works great!

The problem occurred with the other conditional parameter above: taskType, which was presented as follows:

Configuration screen for the Tasks widget in Streaks.

If the user chose "Specific", then an option to choose a specific task is shown. If they choose "Next of Page", then an option with a list of pages is shown, and so on.

In order to model this in an App Entity using parameterSummary, we would then need a different summary for every task type ("automatic", "specific", "next on page", "next in category"), as well as accounting for each chart type. This is 8 combinations.

Now consider another of our widgets: the "Tasks" widget. This lets you choose up to 4 different tasks, and has the same options:

The structure of the Tasks widget, which shows different task selection options based on the task1type, task2type, task3type, task4type values.

In this case, there would be 16 combinations, which really doesn't scale well. It's extremely hard to maintain and is inflexible if future changes are needed.

To solve this, I introduced a new type called TaskTypeAppEntity, which encapsulates the four different types (automatic, specific, next on page, next in category) in a single entity:

struct TaskTypeAppEntity: AppEntity, Identifiable {
    static let typeDisplayRepresentation: TypeDisplayRepresentation = "Task Type"

    let id: TaskTypeAppEntityIdentifier

    var title: String

    var displayRepresentation: DisplayRepresentation {
        .init(title: .init(stringLiteral: title))
    }

    static let defaultQuery = TaskTypeAppEntityQuery()
}

enum TaskTypeAppEntityIdentifier {
    case automatic
    case task(TaskID)
    case category(CategoryID)
    case page(PageID)
}

When building the defaultQuery, it's just a case of including all of the options in that query. You can even group it into separate sections:

The new task type selection screen once the different options are flattened into a single App Entity.

This was achieved with an EntityQuery as follows:

struct TaskTypeAppEntityQuery: EntityQuery {
    typealias Entity = TaskTypeAppEntity

    func suggestedEntities() async throws -> IntentItemCollection<Entity> {

        // The "Automatic Option"
        let automaticItem: IntentItem<Entity> = .init(
            .init(
                id: .automatic,
                title: "Automatic"
            )
        )

        // Build the task items
        let taskItems: [IntentItem<Entity>] = [
            .init(
                Entity(
                    id: .task(/* some ID */),
                    title: "Walk the Dog"
                )
            )
        ]

        // Build the category items
        let categoryItems: [IntentItem<Entity>] = [
            .init(
                Entity(
                    id: .category(/* some ID */),
                    title: "Health & Fitness"
                )
            )
        ]

        // Create the task section, with a title
        let tasksSection: IntentItemSection<Entity> = .init(
            "Task",
            items: taskItems
        )

        // Create the category section, with a title
        let categoriesSection: IntentItemSection<Entity> = .init(
            "Category",
            items: categoryItems
        )

        // Combine all of the sections
        let sections: [IntentItemSection<Entity>] = [
            .init(items: [ automaticItem ]),
            tasksSection,
            categoriesSection
        ]

        return .init(sections: sections)
    }

    // Make the automatic option the default option
    func defaultResult() async -> TaskTypeAppEntity? {
        .init(id: .automatic, title: "Automatic")
    }

    func entities(for identifiers: [Entity.ID]) async throws -> [Entity] {
        // ...
    }
}
Note: The defaultResult() value is very useful here, since it will ensure the correct option is chosen when the widget is first added. It's not possible to specify a default for non-enum @Property directly in the AppIntent, so it must be done here.

Referring back to the "Tasks" widget configuration above, instead of needing 16 combinations to present the widget options nicely, now there is only one. Because of this, I've omitted the parameterSummary completely and let the system generate it.

The four different task types can now be modelled as follows:

struct TasksWidgetConfigurationAppIntent: WidgetConfigurationIntent {

    // other parameters

    @Parameter(title: "Task 1")
    var task1Type: TaskTypeAppEntity

    @Parameter(title: "Task 2")
    var task2Type: TaskTypeAppEntity

    @Parameter(title: "Task 3")
    var task3Type: TaskTypeAppEntity

    @Parameter(title: "Task 4")
    var task4Type: TaskTypeAppEntity

    // other parameters
}

Since the complexity of TaskTypeAppEntity is grouped nicely within its own reusable struct and EntityQuery, the actual widget configuration is far simpler now. Additionally, I'm able to use TaskTypeAppEntity everywhere across Streaks where it makes sense to decide how a task is chosen for display.