Portfoldio
Cross-platform portfolio app with cloud sync and live prices
My own product: a mobile-first portfolio management platform — like Portfolio Performance, but in the cloud and everywhere. Native iOS and Android apps plus web and desktop versions from a single Flutter codebase, Supabase as the backend, GraphQL for type-safe queries, real-time price updates via subscription, an offline-first cache and multilingual UX through gettext.
01 Problem
Portfolio Performance has been the gold standard for serious German investors for years — but it is desktop-only, has no cloud sync, no mobile access. Mobile alternatives are either paywalled, US-centric, or have questionable data quality. Anyone trying to track trades, dividends and tax logic seriously had exactly one option in 2023 — and it ran as a Java/SWT tool on one machine. Anyone wanting to log a trade from their phone simply couldn't.
02 Solution
A Flutter app that is genuinely cross-platform (iOS, Android, web, macOS, Windows, Linux) with Supabase as the cloud backbone, GraphQL via pg_graphql for type-safe queries, fl_chart for visualisation, Hive as an offline cache. Multilingual via gettext (translator-friendly), biometric authentication, in-app purchases for premium features. Six platforms from one codebase and a single build pipeline.
03 Outcome
Live on the App Store and Google Play. Over 95 production builds, active user base. Mobile portfolio management with cloud sync and real-time prices, no US bias and full GDPR compliance. The app is the answer to "Portfolio Performance, but mobile and in the cloud" — and remains so two and a half years after launch.
Six platforms from one codebase
Portfoldio is my own product — which means it’s also the project where I had the most architectural freedom. The uncompromising choice: one codebase for six platforms. iOS, Android, web, macOS, Windows, Linux all run from the same Flutter repo, with minimal platform-specific logic (mainly in-app purchase and biometric auth, both behind abstractions).
This isn’t a “well, the app sort of runs on a Mac” promise — it means a new feature is implemented in one session and ships to all six platforms. That’s the only way a solo developer can take six platforms seriously.
The architecture splits into three cleanly separated layers:
- State layer (BLoC): all state transitions are events, all reads are pure functions over state. With the sheer number of async flows (auth ↔ sync ↔ IAP ↔ subscription ↔ watchlist ↔ live prices), BLoC’s strictness pays off — Provider/Riverpod would be more flexible, but with this many cross-cutting effects it would have become unmanageable.
- Data layer (repository + Hive + GraphQL): every data type has a repository that combines Hive (local cache) and Supabase (cloud source). Writes go out optimistically locally and are synced in the background.
- Presentation layer: widgets that listen directly to BLoC state. No ViewModels, no controllers — the state is the model, the widget renders it.
Approach
The app is organised around three clear axes. The BLoC pattern for state management (especially for the many async flows — auth, sync, subscriptions, in-app purchases). GraphQL codegen against Supabase pg_graphql for type-safe queries and mutations, plus live prices as a GraphQL subscription. Hive as a local cache layer so the app stays usable in airplane mode. Sensitive actions (login, revealing values) are gated behind local_auth (FaceID / TouchID).
On top of that there’s a less visible but equally important block: the build and release pipeline. A Flutter app on six platforms means six chances to get lost in code-signing rabbit holes. We scripted this early — for each platform, one clearly documented path from flutter build to “artifact in the relevant store / distribution pipeline”.
// Sync bloc: coordinates auth state, online state and last sync timestamp.
on<SyncRequested>((event, emit) async {
if (!await _auth.isSignedIn()) return emit(SyncIdle.signedOut());
emit(SyncRunning(startedAt: DateTime.now()));
try {
final remote = await _gql.fetchPortfolio(userId: _auth.userId);
await _cache.replaceAll(remote); // last-write-wins
emit(SyncIdle.success(at: DateTime.now()));
} on NetworkException catch (e) {
emit(SyncIdle.offline(lastSyncAt: await _cache.lastSyncAt()));
} catch (e, s) {
emit(SyncIdle.failed(error: e.toString()));
}
}); Highlights
Portfolio state as a versioned Freezed union
Every possible portfolio state is modelled as an algebraic data type — Loading, Loaded, Syncing, Error. The UI runs an exhaustive switch and the compiler forces every case to be handled. No more “undefined” appearing in production tracking, no “why is the loading spinner still there” — the state cannot be in a hybrid form.
@freezed
class PortfolioState with _$PortfolioState {
const factory PortfolioState.loading() = _Loading;
const factory PortfolioState.loaded({
required List<Position> positions,
required Money totalValue,
required Map<String, Decimal> dailyChange,
required SyncStatus syncStatus,
}) = _Loaded;
const factory PortfolioState.error(String message) = _Error;
}
// Exhaustive switch in the UI — the compiler forces every case.
Widget build(BuildContext context) =>
state.when(
loading: () => const Shimmer(),
loaded: (positions, total, change, sync) =>
PortfolioView(positions, total, change, sync),
error: (msg) => ErrorView(msg),
); Real-time prices via GraphQL subscriptions
pg_graphql in Supabase exposes Realtime subscriptions almost for free. The app opens a WebSocket stream per watchlist and updates the UI without polling. On connection drop, automatic reconnect with backoff kicks in. Behind that sits a trigger on the prices table that fires broadcasts when the price importer writes new rows.
// GraphQL subscription per watchlist — pg_graphql streams.
Stream<Price> watchPrices(List<String> symbols) {
final result = _client.subscribe(
SubscriptionOptions(
document: gql(r"""
subscription WatchPrices($symbols: [String!]!) {
prices(where: { symbol: { in: $symbols } }) {
symbol price change_24h currency updated_at
}
}
"""),
variables: {'symbols': symbols},
),
);
return result
.where((r) => r.data != null)
.map((r) => Price.fromJson(r.data!['prices']));
} Offline-first via Hive + stream watching
Hive boxes cache prices and positions locally. The UI subscribes directly to the box’s stream — when updates arrive over the network, the sync layer writes into the box and the UI re-renders by itself. No extra BLoC in between, no glue code. The app is fully usable in airplane mode, with a clear “last synced X minutes ago” indicator.
class PriceCache {
final Box<Price> _box;
PriceCache(this._box);
Future<Price?> get(String symbol) async => _box.get(symbol);
Future<void> put(Price p) async => _box.put(p.symbol, p);
// UI subscribes directly: sync layer writes, widget re-renders.
Stream<Price?> watch(String symbol) =>
_box
.watch(key: symbol)
.map((e) => e.value as Price?)
.startWith(_box.get(symbol));
} In-app purchases with server-side receipt verification
Apple’s and Google’s built-in receipt checks are fine for toy cases. For an app whose premium features cost real money and whose subscription model has multiple flavours (monthly, yearly, family plan), verification has to run server-side — otherwise a rooted client can claim to have everything unlocked. A Supabase Edge Function takes the receipt, verifies it against the relevant store, writes the result into subscriptions. The client reads its tier from there, not from its own receipt.
serve(async (req) => {
const { receipt, platform, user_id } = await req.json();
const verified = platform === 'ios'
? await verifyApple(receipt)
: await verifyGoogle(receipt);
if (!verified.valid) {
return new Response(JSON.stringify({ tier: 'free' }), { status: 200 });
}
await supabase.from('subscriptions').upsert({
user_id,
tier: verified.tier, // 'plus' | 'pro' | 'family'
expires_at: verified.expiresAt,
platform,
original_transaction_id: verified.txId,
});
return new Response(JSON.stringify({ tier: verified.tier }), { status: 200 });
}); gettext i18n with a real translator workflow
A German finance app isn’t a market for “we’ll use Google Translate, done”. Plural forms (“1 position”, “2 positions”, “0 positions”), per-locale currency formatting, per-locale date formats — handled sloppily, these become a bug source. gettext with .pot/.po files gives me what Crowdin and Weblate need: strings extracted from the code, translators working in their familiar environment, translation memory working out of the box.
#: lib/portfolio/widgets/position_count.dart:24
#, dart-format
msgid "{count, plural, one {1 position} other {{count} positions}}"
msgstr ""
#: lib/portfolio/widgets/last_synced.dart:18
#, dart-format
msgid "Last synced {minutes} min ago"
msgstr ""
#: lib/iap/paywall.dart:42
msgid "Unlock Portfoldio Plus"
msgstr "" What two years in production have shown
Three points I was unsure about at the start, which the operation has clearly settled.
Web is the most important platform, not iOS. Sounds absurd for a “mobile-first” app, but: many users arrive via the web app, try it out, and only install the mobile version afterwards. The web variant therefore has to be of equal quality. Flutter Web is fine for that in 2024+ as long as you build with a frame-budget mindset — no lists with hundreds of rendered items without ListView.builder, no setState at the root of the tree.
Offline-first completely changes the perceived responsiveness. When the user never sees a spinner and instead immediately gets the last known data with the refresh happening in the background, it feels like a native iOS app. That was an architectural premise from day one — and it’s the single biggest reason users stay.
Subscription logic is 5% of the codebase and 30% of the bug reports. Server-side verification, family-plan sharing, restore purchase, in-tier subscription changes, cross-platform subscriptions (someone buys on iOS, wants to use on web) — each of these has its own edge cases. Lesson learned: testable pure functions for “from this receipt follows this tier”, plus a small matrix of integration tests covering the common paths.
05 Key decisions
Architecture choices that paid off
Flutter over native double-implementation
One codebase for iOS, Android, web and desktop. For a solo build this was non-negotiable — maintaining two native apps in parallel would have killed the project. Flutter is production-ready for finance UIs in 2023+, as long as the design system stays disciplined.
Supabase as the whole backend stack
Auth (email, Google, Apple), Postgres with pg_graphql, Realtime for live prices, Storage for receipts. Instead of wiring three different cloud services, everything sits under one console. Self-hosting stays an option if the hosted tier ever stops fitting — important when an app handles financial data.
BLoC + injectable instead of Provider/Riverpod
BLoC forces a clean separation of state and events. With many cross-cutting flows (auth affects sync, sync affects subscription tier, IAP affects features), the structure pays off. `injectable` generates the dependency graph at compile time, so missing bindings don't only show up at runtime.
Hive for offline-first
Every position, every price, every transaction lives locally — the app starts with no network and immediately shows the last known state. Sync happens in the background, with last-write-wins for conflicts and a clear "last synced X minutes ago" indicator as reassurance for the user.
GraphQL codegen via pg_graphql
pg_graphql exposes the Postgres schema as GraphQL without a separate backend. The codegen produces Dart types for every query and subscription — a schema change becomes a compile error. No silent drift between DB and app, no manual JSON mapping.
gettext (.pot/.po) for i18n
Translators work in Crowdin/Weblate, the build extracts strings into `.pot` and integrates translated `.po` files automatically. No more "translator opens the code file and changes strings". Handles plural forms cleanly too, which a finance app ("1 position", "2 positions") cannot ignore.
Biometric authentication for sensitive actions
`local_auth` (FaceID / TouchID) doesn't protect the whole app, just specific actions: restore login, reveal values, log a trade. That keeps the app fast in normal use without leaving social-engineering risks ("my kid glanced at my phone on the bus") wide open.
07 Lessons learned
- 01 Flutter is production-ready for finance apps in 2023+ — provided the design system stays disciplined. Every "cool custom widget" costs you a few frames on the web build.
- 02 Supabase + GraphQL codegen + reactive_forms_annotations: three codegen pipelines that, together, repay their setup cost inside the first week.
- 03 BLoC is more verbose than Provider/Riverpod — and that is exactly the upside for apps with many cross-cutting flows (auth × sync × IAP × subscriptions). "Boilerplate" here is "explicit control flow" there.
- 04 In-app purchases are less painful than their reputation. Apple's and Google's docs have improved surprisingly well in the last few years, and server-side receipt verification is a must — open-source plugins don't all do it for you.
- 05 gettext .pot/.po is the right format because translators actually have proper tools for it (Crowdin, Weblate). "We send a YAML" pipelines reliably lose strings.
- 06 Offline-first isn't a "minor improvement" — it's the difference between "the app loads for three seconds" and "the app is there instantly". In daily use, that's the line between "nice" and "really smooth".