Projects

Launch Control

A focused daily planner for iPhone that you can drive from Siri, Shortcuts and your notifications.

Swift, SwiftUI, SwiftData, App Intents, UserNotifications, Swift Concurrency, Swift Testing, XCUITest

TL;DR

What it does

The app is a vertical pager with three screens:

Screen What it does
Protocol Today’s events, the date, and your current streak
Calendar Pick any date to see, add and manage its events
Completion A weekly bar chart of completed vs. total events, plus a 4-week monthly view

Events are built for quick, gesture-first use: swipe right to complete, swipe left to undo, long-press for notes, double-tap to set a category. Reminders carry a Mark Complete action, so you can finish an event without opening the app. And you can do all of it hands-free with Siri:

Architecture

SwiftUI Views ──► EventEditor (editing state + actions) ──► SwiftData ModelContext
      │                                                         ▲
      └── CompletionCalculator / StreakCalculator (pure logic)  │
                                                                │
App Intents (Siri) ──► IntentContainer (actor) ─────────────────┤
Notification action ──► EventNotificationDelegate ──────────────┘
                     Shared store in the App Group

Views stay thin. Editing logic lives in EventEditor, and anything that’s just math (streaks, week and month ranges, completion counts) lives in stateless types that take an injectable Calendar and reference date, so they’re easy to test.

Code choices and why

One shared store in an App Group

The app has three entry points: the UI, the App Intents that Siri runs, and the notification delegate. If each one opened its own store, a task completed from Siri wouldn’t show up in the app. So all three open the same LaunchControl.store file inside an App Group container.

An actor to hand out the container

App Intents run their queries off the main actor. Under Swift 6 strict concurrency, sharing a ModelContainer across those contexts produced data-race errors. The fix was a small actor that creates the container once and hands it to any async context safely:

private actor IntentContainer {
    static let shared = IntentContainer()
    private var container: ModelContainer?

    func get() throws -> ModelContainer {
        if let existing = container { return existing }
        let schema = Schema([Event.self])
        let appGroupID = "group.AA.Launch-Control"
        let config: ModelConfiguration
        if let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) {
            config = ModelConfiguration(schema: schema, url: groupURL.appendingPathComponent("LaunchControl.store"))
        } else {
            config = ModelConfiguration(schema: schema)
        }
        let fresh = try ModelContainer(for: schema, configurations: [config])
        container = fresh
        return fresh
    }
}

Real entities for Siri, not strings

Events are exposed to Siri as an AppEntity with an EntityQuery and EntityStringQuery. That lets Siri suggest today’s open events and search them by title, instead of making the user say an exact string.

Persistence that heals instead of crashing

If the store fails to load, the app logs the error, deletes the store files and tries again. If that also fails, it falls back to an in-memory store. A planner that crashes on launch is worse than one that starts empty.

A streak rule that matches how people think

StreakCalculator counts consecutive days with at least one completed event. If you haven’t completed anything yet today, it counts from yesterday, so your streak doesn’t drop to zero the moment the day starts.

Accessibility as a feature, not a pass

Every gesture has a VoiceOver equivalent through named actions (mark complete, edit notes, choose category). Pages that aren’t on screen are removed from the accessibility tree, the pager responds to the three-finger scroll, and animations respect Reduce Motion.

Privacy by default

No accounts, no network calls, no analytics. The app ships a privacy manifest that declares no collected data types.

Testing

What’s next