Watcher: App Intents

A desktop app UI showing a movie watch list with a selected movie, "Sicario," and its streaming options.
Watcher on macOS

I've been working on an app called Watcher, which checks whether and where a given movie is streaming. I don't plan on releasing it any time soon, as it currently relies on a data source that I've, uh, scraped together, so to speak. (If anybody knows of a non-paid or cheap API, let me know. The ones I've found so far have all been those unsettling "don't worry about the price yet, tell us about yourself first"-types that never return my emails.)

Anyways, I like over-solving a simple problem, so it's been fun refining the app and trying out new things with it in my spare time. It being WWDC season, I've been watching a lot of session videos lately and using Watcher to try out some of the new frameworks and concepts. If you've been watching along too, you might have noticed App Intents looming large this year as the underpinning for some of the features announced under the "Apple Intelligence" banner.

App Intents give you the ability to extend your app to various parts of the system, including Spotlight, the Lock Screen, Control Center, and Siri. It's a way of encapsulating a piece of functionality and providing it for use à la carte outside the context of your app running in the foreground.

This year, Apple has gone so far as to change their "guidance" on App Intents, which sounds vaguely Vader-y.

 A person presenting with the text "Anything your app does should be an App Intent." displayed beside them on a white background.
From the WWDC 2024 Session "Design App Intents for system experiences." Loving their use of San Francisco Expanded on the slides this year.

Whereas before, the idea was to simply make intents for a handful of only the most crucial features of your app, now they are literally telling you that "anything your app does should be an App Intent."

I've worked with the App Intents framework before for Carpark, but with the above in mind, I thought it might be a good time to start getting in the habit of setting up more intents. Here's how I implemented my first intent for Watcher.

import AppIntents

struct CheckStreamingAvailability: AppIntent {
    static let title: LocalizedStringResource = "Check Streaming Availability"
    static let description: IntentDescription? = "Searches for a movie based on the given title and provides a dialog response describing the movie's streaming availability. The movie is automatically saved in the background to your collection in Watcher."
    
    @Parameter(title: "Movie", description: "The title of the movie for which you want to know the current streaming availability.")
    var movieTitle: String
    
    @MainActor
    func perform() async throws -> some ProvidesDialog {
        let container = DataCenter.shared.container
        let streamingInfoController = StreamingInfoController()
        let results = try await streamingInfoController.searchResults(forTerm: movieTitle)
        
        if let firstResult = results.first {
            let movie = try await streamingInfoController.itemFromSearchResult(firstResult)
            container.mainContext.insert(movie)
            
            switch movie.streamingServices.count {
                case let x where x < 1:
                    return .result(dialog: "No, the \(movie.year ?? "") movie \(movie.title) is not currently available for streaming.")
                case  1...5:
                    return .result(dialog: "Yes, the \(movie.year ?? "") movie \(movie.title) is currently streaming on the following \(movie.streamingServices.count == 1 ? "service" : "services"): \(movie.streamingServices.keys.joined(separator: ", "))")
                case let x where x > 5:
                    return .result(dialog: "Yes, the \(movie.year ?? "") movie \(movie.title) is currently available for streaming on \(movie.streamingServices.count) services.")
                default:
                    return .result(dialog: "No idea, man. Sorry. Some things we just aren't meant to know.")
            }
        } else {
            return .result(dialog: "I'm sorry, I was unable to find a movie by the title \(movieTitle).")
        }
    }
}

My intent implementation.

One thing that might not be obvious to App Intents newcomers is that this is the entirety of it, by which I mean there is nothing else you need to do aside from defining an AppIntent-conforming struct as shown above. You don't need to initialize an instance of it anywhere. Once the app is installed, the system will see that this struct exists in your code and it will be automatically made available in the Shortcuts app and elsewhere.

The title property is required, though the description is optional. The default implementation for each is as a var, though here I've changed them to let constants in order to make them work with strict concurrency as enforced by the new Swift 6 language mode.

While you can leave the description out, I think it's best to put it to use and err on the side of over explaining. Anecdotally, I've found most users I know to have a loose enough grasp on how Shortcuts works that a little hand-holding is appropriate.

Speaking of descriptions, even if you've made App Intents before you may have missed the fact that parameters can have them too. A lot of tutorials and documentation I've found skip them, preferring the title-only initializer, and in iOS 18 you can even skip specifying the title entirely, in which case it'll synthesize one for you based off your variable name. However, like the description for the intent itself, I like providing them for parameters as it's a quick, easy thing to do which can have outsized benefits in terms of user experience.

An important point about the movie parameter here is that I very purposefully did not make it optional. You can have optional intent parameters and they are often (if not even usually) the right way to go. If the user provides no value for the optional parameter, the intent will assume a nil value and continue to call the perform method. If a parameter is non-optional, though, the system will proactively stop and ask the user to provide a value if it's missing, which is exactly what I want here, as this action is pretty useless without a movie to search for.

func perform() async throws -> some IntentResult

The `perform` method signature.

The perform method is the second and final requirement for an App Intent. The method returns a value conforming to the IntentResult protocol, and the default implementation expects you to return an empty intent result by simply calling .result(). This is great if no user feedback is required after the action is performed.

If you want to give back something that the user can use, such as an object or value which they can pass along to another Shortcuts action, or just some information about the action that was just performed, you can use one of these other types which conform to IntentResult: ReturnsValue, ProvidesDialog, or ShowsSnippetView.

For example, returning an Int could look like this:

func perform() async throws -> some ReturnsValue<Int> {
        .result(value: Int.random(in: 1...10))
}

In my case I wanted to take a parameter in the form of a string representing a movie title, use that to perform a search, find the movie, save it to my app's database and then get back to the user with a text description of the movie's streaming availability (or lack thereof.) From the user's perspective, they should be able to just ask Siri about a movie and get a spoken response, with everything else happening invisibly in the background.

ProvidesDialog is perfect for this. It allows you to return a string value which will either be read to the user or display as text on screen depending on the context from which it's invoked. Once I've gotten the needed info here and saved the movie, I use a simple switch statement to create a few different types of response string based on the results.

 iPhone screenshot of a "Check Streaming Availability" shortcut setup and description, showing options for movie title input and next action suggestions.
The App Intent as seen in the Shortcuts app.

At this point, the intent is available as a Shortcuts action. The user can use it in their custom workflows, add it to the home screen, etc. This is great, but it still takes some setup from the user. There is one more step we can take to make this even simpler. We can create an App Shortcut, which acts as a kind of ambassador to the system, spreading the good news of our App Intent.

import AppIntents

struct WatcherAppShortcutsProvider: AppShortcutsProvider {
    @AppShortcutsBuilder
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: CheckStreamingAvailability(), 
            phrases: [
                "Check streaming availability",
                "Check streaming on \(.applicationName)",
                "Check \(.applicationName)"
            ],
            shortTitle: "Check Movie Streaming",
            systemImageName: "tv"
        )
    }
}

The AppShortcutsProvider constructs a collection of all our App Shortcuts. (In this case only the one, of course.) Each is initialized with a title, an SF symbol name, an instance of intent itself, and a list a phrases which when spoken to Siri will cause it to run your intent, allowing you to make multiple guesses at what your users might naturally say to when trying get at this functionality.

Running a search for Tenet via Siri and getting a response saying it's streaming on Tube.

Alright, that's one feature turned into an App Intent! Now for...every other one.

Under Cover

Multiple device mockups "running" the Under Cover app.

Under Cover is an album cover guessing game for music nerds. Something to kill time while waiting for your friend to join you in line at a show. Or, something to pick up every now and then just to remind yourself of your incredible, unparalleled music knowledge.

It’s currently available for iPhone and iPad on the App Store and the code is up on GitHub.

These are some notes on my experience making the app and some of the decisions that went into it.

Gameplay loop

The gameplay is very simple: Each round, the user watches an obscured album cover slowly reveal itself and tries to guess it as quickly as possible. The quicker the correct guess, the better the score. Users can customize the number of rounds (as well as some other parameters if they delve into the settings screen.) That's about it.

I served all roles in the making of the app myself, from concept to design to coding to writing the App Store copy. I wanted to make a small-scale, fun, and modern SwiftUI app with a strong foundation upon which to build. I also wanted to challenge myself on how quickly I could execute. All told, I spent just over 90 hours on it, from creating a new Xcode project to publishing version 1.0 on the App Store. (Though, of course, there is much more work to be done to improve and expand it over time.)

Dev Notes

The app makes use of the MusicKit framework, which handles everything from Apple Music audio playback to fetching album metadata. SwiftData and the Observation framework are used for the model and controllers, respectively. It is 100% SwiftUI with a layout that adapts for both iPhone and iPad.

Before each game the user selects a category, i.e. a pool of albums from which random selections are presented to the user. The album model stores minimal data about an album: album title, artist name, a URL for the album artwork, and the album's ID on Apple Music, as well as a simple boolean to track whether it's been favorited by the user.

I chose to go with an album artwork URL rather than storing the image itself in the model for two reasons. Firstly, storage. Even optimized, those kilobytes can add up quickly! Second, even though album artwork is provided freely with few-to-no qualifications through Apple's own MusicKit API, from past experience I knew that actually storing artwork in-app could risk displeasing app review due to their, um, passionate stance on the copyrighted material of large businesses such as record labels.

This tied in to another concern, which was that of where the above-mentioned categories would come from in the first place. Having the user add albums manually to construct their own categories would ruin some of the surprise. Adding my own preset categories was something I wanted to avoid, at least for the time being, out of an abundance of caution due to those concerns about copyrighted material. It would be best to have an outside data source for albums which could present them grouped in some way that the user could browse and add from at will.

Playlists! Apple Music features public-facing playlists created by themselves, users, and outside curators. While the user has to be a subscriber in order to listen to a playlist, they do not in order to just read its metadata. And once we can do that, we can make separate requests to the MusicKit catalog for all the tracks' individual albums with their covers and other metadata — everything we need for the model.

A screenshot of Under Cover showing a sheet with a textfield where the text “punk r” has been entered. Below is a list of punk playlists from Apple Music.
The playlist search live results interface.

To facilitate this I implemented playlist search with live results presented and updated as the user enters their query. This involved managing multiple asynchronous music catalog resource requests so as to provide a responsive UI with meaningful feedback for the user while avoiding slowing things down.

After watching Alex Nagy's video on MVC in SwiftUI I was eager to try out a similar pattern here. The app uses two controller classes, both made using the @Observable macro and initialized and inserted into the app environment at launch: a single player game controller to manage gameplay and an Apple Music controller to monitor and react to things like subscription status and user authorization.

After the user has selected a category, set the number of rounds, and initiated gameplay, a GameState enum is initialized and changes to its value determine the user interface.

In addition to letting the user set the number of rounds before each game, the settings screen features further game customization options, such as a black & white mode for increased challenge. All of these settings are stored in User Defaults, with @AppStorage leveraged for use within views. Lately, in most of my projects, I’ve taken to creating a simple StorageKeys enum to keep things tidy when going between @AppStorage and accessing UserDefaults directly (the former being a wrapper for the latter.)

A screenshot of the settings screen. It is a list which feature settings for gameplay configuration and whether or not to play music, as well as links to a game overview and photo credits.

As noted above, all of the MusicKit features covered up to this point are available whether or not the user actually pays for Apple Music. MusicKit's functionality as a catalog data source only relies on the developer registering the app for the MusicKit service entitlement, setting the proper Info.plist values, and prompting for permission. However, if the user does have an active subscription, Under Cover takes advantage of that to offer the ability to hear the albums as they're guessing them. To accomplish this, the single player game controller uses the system music player singleton and manages its queue as the game progresses, playing a random song from each album during the guessing stage.

Design Notes

In line with the decision to move quickly, I did not do extensive wireframing or mockups. Instead, I decided to take some time to think through the app's needs, come up with a direction and some starting components, and then proceed to "design in code."

I've found Freeform to be a great tool for this kind of conceptual work (and for organizing thought during development overall.) Having a bucket to collect and draw connections between everything from links to graphic inspiration to API documentation pull quotes to sketches and diagrams and to-do lists works very well for the way I think and helps me keep the big picture in mind throughout.

A screenshot of a Freeform board featuring a messy diagram containing notes on the app's views and data.
Diagramming the app at an early stage. It ain't pretty, and that's partly the point

I designed the data model in parallel with diagramming the user flow, iteratively determining what data really needed to be stored based on what I want the user to see and do.

When it came to settling on an overall visual direction I had a few primary points I was considering:

  • Fun. Games are supposed to feel fun, right? I knew I wouldn't have time to implement the kinds of shiny, maximalist design techniques I usually see used to convey fun. Maybe I could go more for something irreverent or even, dare I dream, funny.
  • Staying out of the way. Since the key interaction in the app is studying an album cover as it slowly de-blurs, I wanted to create a neutral environment that wouldn't fight for attention.
  • “Cool,” or something. It was important for me that it looks cool because I like things that look cool! I don't know why we as designers have decided it would be literal death to ever admit this, but aesthetics "for their own sake" do actually provide value in that humans appreciate aesthetics and it's nice when things are nice. Also, it's a music game. People who like music are cool (or at least want to feel that way.)
  • Quick! Remember, I wanted something I could execute on fast.

So, I needed to do a lot with a little. I wanted something irreverent and immediate, minimal and to the point. What better a jumping off point then than one of the great music genres of all time, punk rock?

A collage of show posters for Siouxie and the Banshees, GBH, Bad Brains and more.

The classic punk rock show poster has a defined aesthetic based on limitations. Black & white, high contrast. Cheaply printed with images rendered in low quality. Lots of literal "whitespace." And they were fun! Even the bad ones were charming in their own way.

This served as a good point of focus. While the end product is in no way meant to literally evoke a show poster, the concept provided some useful constraints and the ingredients for a consistent visual language.

Several iPhone mockups showing different font pairings.
Type tests

For type, after some trial and error, I landed on the pairing of SF Mono for body and PP Nikkei Maru Ultrabold for display. A monospaced font is a perfect fit for the punk poster aesthetic, and Nikkei Maru's Ultrabold's forcefulness is just enough of "too much."

Four photos, each in black and white with duotone treatment and yellow text over top.
At the end of each game the user is given a reward/consolation in the form of a lovely jpeg.

As a further tie-in to the poster idea, I applied halftone printing effect to a collection of photos which show when the user completes a game.

Two Figma mockups showing designs for the category index and round results screens for the app.
That Wayne Shorter album rips, btw.

I drafted a couple sections of the app in Figma to give myself a jumping off point. The screen designs here were mostly unaltered in the shipping product and were extrapolated on for the other sections of the app. The simple pill button style became a key component, as did the short-throw drop shadows to highlight album covers and other featured elements.

A Brief, Practical Introduction to Custom Fonts in SwiftUI

We love Apple's system fonts but sometimes one wants to spread their wings and fly free. Here's a simple guide to using a custom font in your SwiftUI project.

Demo Project

Behold the best albums of last year, objectively. (Project available on my GitHub.)

I'm pretty happy with the layout above. Let's see if we can mess it up a little!

Get a Font

First you need a font file ("cool-font.ttf", "cool-font.otf", etc.). For the purposes of this demo project I'll be using Hatton Bold from the wonderful Pangram Pangram type foundry.

Put the Font in Xcode

Import the font file into your project. (Just drag it in.)

When importing you may be asked if you want to add this file to your app target(s). You do.

You will also need to specify the font in your info.plist. Add the key "Fonts provided by application" and make the first value the name of your font file including its file extension.

Put the Font in a View

To apply this font to a view we just need to use the .font modifier and the Font.custom(_:size:) method, specifying the name of our font without its file extension.

Text("2023")
  .font(.custom("PPHatton-Bold", size: 56))

Put the Font in the Navigation Bar

Applying it to the Navigation Title means we have to interact with a UIKit class, UINavigationBar, at least for now. (I expect a more SwiftUI-ish way to come about at some point.)

.largeTitle and .inline modes are set separately. In our .onAppear block we'll create two fonts and apply them.

struct HomeView: View {
    @State private var top12: [GoodAlbum] = GoodAlbum.top12of2023
    
    var body: some View {
        NavigationStack {
            VStack {
                // Cool stuff...
            }
            .navigationTitle("Top 12 Albums")
            .onAppear {
                let customLargeTitle = UIFont(name: "PPHatton-Bold", size: 48)!
                UINavigationBar.appearance().largeTitleTextAttributes = [.font: customLargeTitle]
                
                let customInline = UIFont(name: "PPHatton-Bold", size: 20)!
                UINavigationBar.appearance().titleTextAttributes = [.font: customInline]
            }
        }
    }
}

If force-unwrapping these UIFonts is wrong, I don't wanna be right.

There are plenty of other properties you can change in these text attributes dictionaries, including things like tracking and kerning. Worth poking around.

Those numerals 🤤

Make it Nicer

If you're like me you're going to change your mind on the font about 50 times. Let's create a quick Font extension so that we only have to write/overwrite the font name in one place.

extension Font {
    static func displayFont(ofSize size: CGFloat) -> Font {
        return Font.custom("PPHatton-Bold", size: size)
    }
}