Native iOS todos with an interactive widget
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
Todos— the SwiftUI app, dark UI, iPhone + iPadTodosWidget— WidgetKit extension, three sizes- App Group for shared UserDefaults (bearer token + cached snapshot)
- iOS 17 deployment target — required for interactive widget AppIntents
- XcodeGen-managed project so the spec is in version control
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:
POST /api/login-token— exchanges a password for an HMAC-signed bearer token, same signer as the web session cookie, 30-day TTL.GET /api/todos/today— open-only items plus anopen_count, so the widget gets the data it needs in one round trip.
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.