Back to blog
FILE 0x62·NATIVE IOS TODOS WITH AN INTERACTIVE WIDGET

Native iOS todos with an interactive widget

May 7, 2026 · ios, swiftui, widgetkit

I ship enough native iOS to make my own todo app worth the effort. I already had a backend that powered the web UI; I wanted a SwiftUI app that hit the same API, plus a widget I could actually check items off from. iOS 17's interactive widgets made that last part finally work.

The shape

The widget shows today's open items. The biggest size (.systemExtra Large, iPad-only) fits 13 rows; medium fits 4, large fits 9. There's a plus button in the header that opens the app to compose, and an "All done!" trophy state when the list is empty.

Live tappable checkboxes

This is the part I'd waited for iOS to grow up enough to do. Define an AppIntent that takes the todo ID:

struct CheckTodoIntent: AppIntent {
    static var title: LocalizedStringResource = "Toggle Todo"
    @Parameter(title: "Todo ID") var todoID: String

    func perform() async throws -> some IntentResult {
        try await API.shared.toggle(todoID: todoID)
        WidgetCenter.shared.reloadAllTimelines()
        return .result()
    }
}

Then wire it into the row view:

Button(intent: CheckTodoIntent(todoID: todo.id)) {
    Image(systemName: todo.done ? "checkmark.circle.fill" : "circle")
}

Tap the checkbox in the widget. The intent runs in the widget extension's process, hits the backend, persists the toggle, and reloads the timeline. The transition is instant.

Backend additions

Two endpoints added to the existing backend to support the app:

The existing require_auth middleware now accepts either the web cookie or Authorization: Bearer .... Same signer, two transports.

Shared snapshot via App Group

The widget can't make its first render wait on a network call. The app writes a JSON snapshot of "today's items" to the App Group UserDefaults every time it refreshes. The widget reads that on render and shows it immediately, then kicks off a network refresh in the background. Round-trip latency goes from "blink while loading" to "instant render, maybe a content shift if the server has newer data."

Scheduling and recurrence

Both the web and the iOS app share a /api/todos/parse endpoint that takes natural language and returns a structured schedule. Tap the date pill on a row, get an inline editor with presets ("today 5pm", "tomorrow 9am", "next Mon", "+1 week", "weekdays 9am", "MWF 7am") plus a free-text input with live preview. Scheduling clears notified_at on the item so the reminder re-fires at the new time.

This is the kind of feature where being able to write both the frontend and the backend in the same session makes a huge difference. The parser, the UI, and the side-effect on notified_at all landed together.

What I'd do differently

I'd write the AppIntent contract first, before the row view, before the timeline provider, before anything else. The widget rewrite is small enough that the contract drives everything: the intent's parameter shape determines what state the widget needs, which determines what the snapshot needs to carry. Working forward from the visual design out toward the intent caused me to refactor the intent two or three times. Working backward from the intent would have been a straight line.