【iOS】Siriからサクッと注文する機能を作ってみた With App Intents

【iOS】Siriからサクッと注文する機能を作ってみた With App Intents

iOS 16からでも使える機能を使用して、Siriからサクッと注文ができる機能を作ってみることにしました。
Clock Icon2024.07.10 03:11

今年のWWDC24では、「App Intentsが面白くなってぞ!」と感じた方も多いのではないでしょうか?

実はApp Intents自体はWWDC22で発表が行われており、すでに色々な場面で恩恵を受けることができます。
なので、今回はApp Intentsのキャッチアップも兼ねて、iOS 16からでも使える機能を使用して、Siriからサクッと注文ができる機能を作ってみることにしました。

環境

  • Xcode 15.2
  • iOS 17.5.1

作ったもの: Siriから注文する機能

Siri上で注文を依頼すると、まずは商品の選択肢が表示され、その後、サイズの選択肢が表示されます。注文内容で問題なければ、商品を注文できる機能です。

https://youtu.be/mXtWtUqe88E

実装

準備

今回、データベースはSwiftDataを用いて表現します。今回の伝えたい部分ではないので詳細は割愛します。

SwiftData

Model
import Foundation
import SwiftData

@Model
final class OrderItem {
    let orderID: UUID
    let product: ProductType
    let size: Size
    let orderDate: Date

    init(orderID: UUID = UUID(),
         product: ProductType,
         size: Size,
         orderDate: Date = .now) {
        self.orderID = orderID
        self.product = product
        self.size = size
        self.orderDate = orderDate
    }

    /// 商品とサイズから算出した価格
    var price: Int {
        let price = product.value.basePrice * size.value.priceMultiplier
        return Int(round(price))
    }
}

ProductSizeについては後述します。

注文商品を表すモデルで、商品情報やサイズから価格を算出できます。

DataStore
import SwiftData
import Foundation

class OrderDataStore {

    private init() {
        let schema = Schema([
            OrderItem.self,
        ])
        let configuration = ModelConfiguration(schema: schema,
                                               isStoredInMemoryOnly: false)

        do {
            container = try ModelContainer(for: schema,
                                           configurations: [configuration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }

    static let shared = OrderDataStore()

    let container: ModelContainer

    @MainActor
    var context: ModelContext {
        return container.mainContext
    }
}

また、IDと一致する注文商品を取得するメソッドと、最新5つまでの注文履歴を取得できるメソッドを事前に用意しておきます。

extension OrderDataStore {

    @MainActor
    func fetchItem(id: UUID) -> OrderItem? {
        let descriptor = FetchDescriptor<OrderItem>(predicate: #Predicate { $0.orderID == id })
        if let item = try? context.fetch(descriptor).first {
            return item
        } else {
            return nil
        }
    }

    @MainActor
    func fetchRecentFiveOrders() -> [OrderItem] {
        var descriptor = FetchDescriptor<OrderItem>(sortBy: [SortDescriptor(\.orderDate, order: .reverse)])
        descriptor.fetchLimit = 5
        if let items = try? context.fetch(descriptor) {
            return items
        } else {
            return []
        }
    }
}

AppEnumを作成する

今回はSiriからの応答で選択肢を出す必要がある為、AppEnumを作成します。

AppEnum

AppEnumは、ショートカットに開発者が定義した型をショートカットに設定する為に使用します。AppEnumに準拠するには、StaticDisplayRepresentableに準拠する必要があり、値の文字列ベースの表現を提供することができます。

AppEnumに準拠すると、下記のプロパティの追加を求めれます。

// ショートカットで表示されるタイプの名前
static var typeDisplayRepresentation: TypeDisplayRepresentation { get }

// ショートカットで表示されるケースごとのタイトルなどの情報
static var caseDisplayRepresentations: [Self : DisplayRepresentation] { get }

ProductType

ProductTypeは商品のタイトルや価格、画像名の情報を持っています。

import AppIntents

enum ProductType: String, Codable {
    case hamburger
    case beer

    var value: Value {
        switch self {
        case .hamburger:
            return Value(displayTitle: "ハンバーガー",
                         basePrice: 300,
                         imageName: "hamburger")
        case .beer:
            return Value(displayTitle: "ビール",
                         basePrice: 200,
                         imageName: "beer")
        }
    }

    struct Value {
        let displayTitle: LocalizedStringResource
        let basePrice: Double
        let imageName: String
    }
}

AppEnumにも準拠しており、タイプ名には商品、ケースの名前をそれぞれ指定しています。

extension ProductType: AppEnum {
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "商品"

    static var caseDisplayRepresentations: [ProductType: DisplayRepresentation] = [
        .hamburger: "ハンバーガー",
        .beer: "ビール"
    ]
}

Size

Sizeはサイズ情報、そのサイズにした時の価格を調整する係数を持たせています。

import AppIntents

enum Size: String, Codable {
    case small
    case medium
    case large

    var value: Value {
        switch self {
        case .small:
            return Value(displayTitle: "S",
                         priceMultiplier: 1.0)
        case .medium:
            return Value(displayTitle: "M",
                         priceMultiplier: 1.5)
        case .large:
            return Value(displayTitle: "L",
                         priceMultiplier: 2.0)
        }
    }

    struct Value {
        let displayTitle: LocalizedStringResource
        /// サイズによって価格を調整するための係数
        let priceMultiplier: Double
    }
}

こちらもAppEnumに準拠しており、各値を指定しています。

extension Size: AppEnum {
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "サイズ"

    static var caseDisplayRepresentations: [Size: DisplayRepresentation] = [
        .small: "S",
        .medium: "M",
        .large: "L"
    ]
}

注文用のAppIntentを作成する

AppIntent

Siriやショートカットアプリなどのシステムサービスからユーザーが呼び出すアプリ固有の機能を提供するために使用するインターフェイスです。

OrderIntent

今回は注文用のAppIntentを作成しました。

import SwiftUI
import AppIntents

struct OrderIntent: AppIntent {

    static var title: LocalizedStringResource = "注文する"

    @Parameter(title: "商品名", requestValueDialog: "どの商品にしますか?")
    var product: ProductType

    @Parameter(title: "サイズ", requestValueDialog: "どのサイズですか?")
    var size: Size

    @MainActor
    func perform() async throws -> some IntentResult & ProvidesDialog {
        let order = OrderItem(product: product, size: size)
        try await requestConfirmation(result: .result(dialog: "こちらの注文でよろしいでしょうか?") {
            OrderConfirmationView(order: order)
        }, confirmationActionName: .order, showPrompt: true)

        OrderDataStore.shared.context.insert(order)
        return .result(dialog: "注文が完了しました!")
    }

    static var parameterSummary: some ParameterSummary {
        Summary("\(\.$product)\(\.$size)で注文する")
    }
}
title

AppIntentのタイトルを設定します。

static var title: LocalizedStringResource = "注文する"
@Parameter

@Parameterを使用することでAppIntentをカスタムできるようになります。

@Parameter(title: "商品名", requestValueDialog: "どの商品にしますか?")
var product: ProductType

今回、titlerequestValueDialogを設定しており、このrequestValueDialogはユーザーに選択肢を問う際に表示される文言になります。

perform

AppIntent実行時に行う処理を記述します。

func perform() async throws -> some IntentResult & ProvidesDialog {
    let order = OrderItem(product: product, size: size)
    try await requestConfirmation(result: .result(dialog: "こちらの注文でよろしいでしょうか?") {
        OrderConfirmationView(order: order)
    }, confirmationActionName: .order, showPrompt: true)

    OrderDataStore.shared.context.insert(order)
    return .result(dialog: "注文が完了しました!")
}

流れとしては、下記のようになっています。

  1. ユーザーが入力した商品、サイズからOrderItemを作成
  2. 入力情報が正しいかユーザーに確認
  3. 注文履歴にインサート
  4. 注文完了ダイアログを表示
requestConfirmation

ユーザーからの要求を確認する為に使用するメソッドです。

func requestConfirmation<Result>(
    result: Result,
    confirmationActionName: ConfirmationActionName = .`continue`,
    showPrompt: Bool = true
) async throws where Result : IntentResult

確認で使用するresultのダイアログで、カスタムViewを使用することができる為、独自で実装したOrderConfirmationViewを使用しています。

confirmationActionNameは、確認画面上で使用する確認ボタンのアクション名を指定することが出来ます。

showPromptでは、文字列としてのプロンプトを表示するか指定することが出来ます。resultでカスタムViewを使用する際などで内容が重複してしまう場合などはfalseにして非表示にすることができます。

OrderConfirmationView

requestConfirmationで使用するカスタムViewも下記のようにSwiftUIで作成したViewを使用することができます。

import SwiftUI

struct OrderConfirmationView: View {

    let order: OrderItem

    var body: some View {

        VStack {
            HStack {
                Image(order.product.value.imageName)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 120)

                VStack(alignment: .leading) {
                    Text("商品: \(order.product.value.displayTitle)")
                    Text("サイズ: \(order.size.value.displayTitle)")
                }
            }
            .padding()

            Divider()

            Text("合計: \(order.price)円")
                .frame(maxWidth: .infinity, alignment: .trailing)
                .padding()
        }
        .font(.title3)
    }
}

AppShortcutを作成する

AppShortcutsProvider

AppShortcutsProviderはアプリインストール時にショートカットを作成するためのインタフェースが定義されているプロトコルです。

こちらを使用することでこれまでのようにApp Extensionを追加する必要がなく、簡単にショートカット機能を実装できるようになります。

OrderAppShortcuts

import AppIntents

struct OrderAppShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: OrderIntent(),
            phrases: ["\(.applicationName)でオーダーする",
                      "\(.applicationName)でオーダー",
                      "\(.applicationName)で注文する",
                      "\(.applicationName)で注文"],
            shortTitle: "フードを注文する",
            systemImageName: "takeoutbag.and.cup.and.straw.fill"
        )
    }

    static var shortcutTileColor: ShortcutTileColor = .grayGreen
}

AppShortcutintentには、ショートカット実行時に呼び出したいAppIntentを設定します。

pharaseには、Siri呼び出しの際に反応する為の文言を指定します。さまざまなユーザーの言い回しに対応できるようにする為に考えられる限りのフレーズを追加する必要があるように思えました。

shortTitlesystemImageNameはショートカットとして表示される際のタイトルとアイコンイメージになります。

ここまでの実装で、Siriから注文する機能が表現できるようになりました。

複数選択肢がある場合のデメリット

今回は一品ずつしか注文できないですが、これがもし複数注文できた場合、そのたびに商品やサイズの選択を確認する状態では、ユーザーはやや鬱陶しいと感じて途中で注文するのを諦めてしまうかもしれません。

なので、一度注文した履歴の中から注文できる機能の実装にもチャレンジしてみることにしました。

作ったもの: Siriで注文履歴から再注文できる機能

Siri上で再注文を依頼すると注文履歴が表示され、その中から再注文した商品を選択。注文内容で問題なければ、商品を注文できる機能です。

https://youtu.be/iQl8HyHoO1c

AppEntity

Siriやショートカットなどにアプリが提供する特定のアクションやデータを公開するためのインターフェイスです。

ReorderItem

注文履歴用のモデルです。

import AppIntents

struct ReorderItem: Identifiable {
    let id: UUID
    let product: ProductType
    let size: Size
    let orderDate: Date

    init(orderItem: OrderItem) {
        self.id = orderItem.orderID
        self.product = orderItem.product
        self.size = orderItem.size
        self.orderDate = orderItem.orderDate
    }
}

extension ReorderItem: AppEntity {

    static var typeDisplayRepresentation: TypeDisplayRepresentation {
        return "再注文商品"
    }

    var displayRepresentation: DisplayRepresentation {
        let title: LocalizedStringResource = "\(product.value.displayTitle)\(size.value.displayTitle)サイズ"
        return DisplayRepresentation(title: title)
    }

    static var defaultQuery = ReorderItemQuery()
}

displayRepresentationはこのAppEntityを表示する際の値を記述します。

またAppEntitydefaultQueryが必須パラメータになっており、アプリ内で使用しているデータを取得するために使用します。

ReorderItemQuery

defaultQueryEntityQueryに準拠している必要があります。

EntityQueryは識別子を使用してエンティティをクエリする為のインターフェースです。準拠する為に下記二つのメソッドの記述を求められます。

// MARK: - EntityQuery for ReorderItem
struct ReorderItemQuery: EntityQuery {

    // 識別子からエンティティを取得する
    func entities(for identifiers: [UUID]) async throws -> [ReorderItem] {
        var items: [ReorderItem] = []
        for i in 0..<identifiers.count {
            if let item = await OrderDataStore.shared.fetchItem(id: identifiers[i]) {
                items.append(ReorderItem(orderItem: item))
            }
        }
        return items
    }

    // このクエリによってサポートされるオプションのリストが提示されたときに表示される初期結果を返す
    func suggestedEntities() async throws -> [ReorderItem] {
        // 最近の5つまでの注文情報を返す
        let items = await OrderDataStore.shared.fetchRecentFiveOrders()
        return items.compactMap({ ReorderItem(orderItem: $0) })
    }
}

再注文用のAppIntentを作成する

ReorderIntent

import AppIntents
import SwiftUI

struct ReorderIntent: AppIntent {

    static var title: LocalizedStringResource = "注文履歴から注文する"

    @Parameter(title: "注文履歴", requestValueDialog: "どちらを再注文しますか?")
    var item: ReorderItem?

    @MainActor
    func perform() async throws -> some IntentResult & ProvidesDialog {
        let reorderItem = try await $item.requestDisambiguation(
            among: OrderDataStore.shared.fetchRecentFiveOrders().map({ ReorderItem(orderItem: $0) }),
            dialog: IntentDialog("どちらの商品を再注文しますか?")
        )

        let order = OrderItem(product: reorderItem.product, size: reorderItem.size)
        try await requestConfirmation(result: .result(dialog: "こちらの注文でよろしいでしょうか?") {
            OrderConfirmationView(order: order)
        }, confirmationActionName: .order, showPrompt: true)

        OrderDataStore.shared.context.insert(order)
        return .result(dialog: "注文が完了しました!")
    }

    static var parameterSummary: some ParameterSummary {
        Summary("\(\.$item)を再注文する")
    }
}

注文時のOrderIntentと変わっている点としては、var item: ReorderItem?と入力される値がオプショナルになっています。

注文情報がオプショナルとなっている為、perform内で入力情報の曖昧さを回避する為に、requestDisambiguationを実行して該当する値を取得します。

WWDC22のセッション: Design App Shortcutsの中でも5 個以下の値のリストに最適であることに留意してくださいとあったので、直近の5つの注文情報を表示して、その中から再注文する商品を選ぶことにしました。

それ以降の流れについては、OrderIntentと同様に確認ダイアログを表示して、注文という流れになっています。

AppShortcutを追加する

今回、ReorderIntentも実行するSiri、ショートカット経由で実行する必要があるのでOrderAppShortcutsに追加します。

import AppIntents

struct OrderAppShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: OrderIntent(),
            phrases: ["\(.applicationName)でオーダーする",
                      "\(.applicationName)でオーダー",
                      "\(.applicationName)で注文する",
                      "\(.applicationName)で注文"],
            shortTitle: "フードを注文する",
            systemImageName: "takeoutbag.and.cup.and.straw.fill"
        )
+      AppShortcut(
+          intent: ReorderIntent(),
+          phrases: ["\(.applicationName)で注文履歴からオーダーする",
+                    "\(.applicationName)で注文履歴からオーダー",
+                    "\(.applicationName)で注文履歴から注文する",
+                    "\(.applicationName)で注文履歴から注文"],
+          shortTitle: "フードを注文履歴から注文する",
+          systemImageName: "takeoutbag.and.cup.and.straw.fill"
        )
    }

    static var shortcutTileColor: ShortcutTileColor = .grayGreen
}

以上でSiriからサクッと注文、再注文ができるようになりました。

コード

GitHubに置いております。

https://github.com/littleossa/AppShortCutPractice

終わりに

AppShortcutphraseについては、ちゃんと認識してくれなかったりするので、何パターンも用意したり、良いフレーズを慎重に検討する必要はあるかもしれません。

また、アプリで作成できるショートカットは最大10個です。アプリにフォーカスする必要がなくても自己完結できる機能だけに絞り、最適なショートカット機能を作成しましょう!

参考

https://zenn.dev/naoya_maeda/articles/51891f1876e12b
https://developer.apple.com/documentation/appintents
https://developer.apple.com/documentation/appintents/appenum?language=_5
https://developer.apple.com/documentation/appintents/staticdisplayrepresentable?language=_5
https://developer.apple.com/documentation/appintents/casedisplayrepresentable/casedisplayrepresentations?language=_5
https://developer.apple.com/documentation/appintents/appintent?language=_5
https://developer.apple.com/documentation/appintents/adding-parameters-to-an-app-intent
https://developer.apple.com/documentation/appintents/appintent/requestconfirmation(result:confirmationactionname:showprompt:)
https://developer.apple.com/documentation/appintents/appentity
https://developer.apple.com/jp/videos/play/wwdc2022/10032/
https://developer.apple.com/videos/play/wwdc2022/10170
https://developer.apple.com/videos/play/wwdc2022/10169

この記事をシェアする

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.