iOS Widgets
Glanceable updates
Overview
The app ships with three iOS home screen widgets built using expo-widgets. PortfolioSummaryWidget shows your total portfolio value and day change in small and medium sizes. TopHoldingsWidget displays your top 4 holdings with values and changes. QuickAddWidget is a minimal widget with a button that deep links to the add transaction screen. Widgets cannot make network requests on iOS, so all data gets pushed from the app using updateWidgetSnapshot. The widget data builder reads directly from Zustand stores using getState() and formats everything into a flat props object that SwiftUI can consume.
How it works
The widget components use primitives from @expo/ui/swift-ui. These are not React Native views. They compile to actual SwiftUI code that iOS renders natively in the widget.
The data flow starts in useWidgetUpdater, a hook that runs on the portfolio screen. Whenever prices update or the portfolio changes, it calls buildWidgetPayload(prices) which reads from usePortfolioStore.getState() and usePortfolioCalculations.getState() to compute all the numbers. The payload is a flat object with string and number values only, no nested objects, because the SwiftUI bridge works best with simple types.
The payload includes formatted values ("$12,345.67"), colors ("#34C759" for positive, "#FF3B30" for negative), trend icons, and data for up to 4 holdings. A defensive fallback uses the cached currentPrice from the store if the live prices map is empty, which prevents widgets from showing $0.00 during the brief window between app launch and the first price fetch.
updateAllWidgets(prices) lazy imports the widget components (to avoid loading SwiftUI on Android), then calls updateWidgetSnapshot for each of the three widgets. The snapshot freezes the current data into the widget until the next update. Widgets do not poll or refresh on their own.
PortfolioSummaryWidget supports two sizes. systemSmall shows the portfolio name, total value, trend arrow, and percent change. systemMedium adds the top 3 holdings on the right side with their symbols and day changes.
QuickAddWidget uses a Button component with a target prop set to "QuickAddWidget". When tapped, the main app receives the interaction through addUserInteractionListener, and navigates to the add transaction screen.
Key decisions
Flat payload instead of nested objects
The widget props use flat keys like h0Symbol, h0Value, h1Symbol, h1Value instead of an array of holdings objects. This is because the SwiftUI bridge serializes props through a narrow channel. Flat key value pairs are reliable. Nested objects and arrays can cause issues with the alpha version of expo-widgets. It is less elegant in the code, but it works consistently.
Push data from app, not pull from widget
iOS widgets cannot make network requests (WidgetKit limitations). We could have used App Groups with shared UserDefaults, but expo-widgets provides updateWidgetSnapshot which handles the data transfer. The widget always shows the last data the app pushed. If the app has not been opened in a while, the widget might show stale data, but that is an acceptable tradeoff for the simplicity of the approach.
Defensive fallback for empty prices
When the app launches, there is a brief moment where live prices have not loaded yet but the stores have cached currentPrice values from the last session. The widget data builder checks if the live prices map is empty and falls back to these cached prices. Without this, the widget would flash $0.00 every time you open the app. If the total value is still 0 after fallback, it returns null to preserve the previous widget snapshot entirely.
expo-widgets alpha, not native Swift
We could have written the widgets in pure Swift using WidgetKit directly. That would give us more control and features like timeline providers for scheduled refreshes. But expo-widgets lets us stay in TypeScript and share types with the rest of the app. For our use case (displaying data the app pushes), the alpha package does everything we need. If we hit limitations later, migrating to native Swift is always an option.