Skip to content
Zurück zur Projektübersicht
Live 2023 — heute · 9 Min. Lesezeit

Portfoldio

Cross-platform Portfolio-App mit Cloud-Sync und Live-Kursen

Eigenes Produkt: eine mobile-first Portfolio-Management-Plattform — wie Portfolio Performance, nur in der Cloud und überall. Native Apps für iOS und Android plus Web- und Desktop-Versionen aus einer Flutter-Codebasis, Supabase als Backend, GraphQL für typsichere Abfragen, Echtzeit-Kursupdates per Subscription, Offline-First-Cache und mehrsprachige UX über gettext.

01 Problem

Portfolio Performance ist seit Jahren der Goldstandard für ernsthafte deutsche Anleger — aber Desktop-only, kein Cloud-Sync, kein mobiler Zugriff. Mobile Alternativen sind entweder paywalled, USA-zentriert oder bei der Datenqualität fragwürdig. Wer seine Trades, Dividenden und Steuerlogik sauber tracken will, hat in 2023 genau eine seriöse Option — und die läuft als Java-SWT-Tool auf einem Rechner. Wer mobil unterwegs einen Trade eintragen will, kann das schlicht nicht.

02 Lösung

Eine Flutter-App, die echte Cross-Platform ist (iOS, Android, Web, macOS, Windows, Linux), mit Supabase als Cloud-Backbone, GraphQL über pg_graphql für typsichere Abfragen, fl_chart für die Visualisierung, Hive als Offline-Cache. Mehrsprachig über gettext (Translator-friendly), biometrische Authentifizierung, In-App-Purchases für Premium-Features. Sechs Plattformen aus einer Codebasis und eine einzige Build-Pipeline.

03 Ergebnis

Live in App Store und Google Play. Über 95 Builds in Produktion, aktive Nutzerbasis. Mobile Portfolio-Verwaltung mit Cloud-Sync und Echtzeit-Kursen, ohne USA-Bias und mit voller DSGVO-Konformität. Die App ist die Antwort auf „Portfolio Performance, aber bitte mobil und in der Cloud" — und ist das zweieinhalb Jahre nach Launch immer noch.

Sechs Plattformen aus einer Codebasis

Portfoldio ist mein eigenes Produkt — und damit auch das Projekt, in dem ich die größten Freiheitsgrade bei Architekturentscheidungen hatte. Die kompromisslose Wahl: eine Codebasis für sechs Plattformen. iOS, Android, Web, macOS, Windows, Linux laufen aus dem gleichen Flutter-Repository, mit minimaler plattformspezifischer Logik (vor allem In-App-Purchase und biometrische Authentifizierung, beide hinter Abstraktionen).

Das ist kein „dann läuft die App halt auf dem Mac”-Versprechen — es bedeutet, dass eine neue Funktion in einer Session implementiert und auf allen sechs Plattformen ausgeliefert wird. Das ist die einzige Art, wie ein Solo-Entwickler überhaupt sechs Plattformen seriös bedienen kann.

Die Architektur teilt sich in drei klar getrennte Ebenen:

  1. State-Layer (BLoC): alle State-Übergänge sind Events, alle Reads sind reine Funktionen über den State. Bei der schieren Zahl asynchroner Flows (Auth ↔ Sync ↔ IAP ↔ Subscription ↔ Watchlist ↔ Live-Kurse) zahlt sich BLoC mit seiner Strenge aus — Provider/Riverpod wäre flexibler, aber bei diesem Maß an Cross-Cutting-Effekten unübersichtlich geworden.
  2. Data-Layer (Repository + Hive + GraphQL): jede Datenart hat ein Repository, das Hive (lokaler Cache) und Supabase (Cloud-Quelle) zusammenführt. Schreibvorgänge gehen optimistisch lokal raus und werden später im Hintergrund synchronisiert.
  3. Presentation-Layer: Widgets, die direkt auf BLoC-States hören. Keine ViewModels, keine Controller — der State ist das Modell, das Widget rendert ihn.

Vorgehen

Die App ist um drei klare Achsen organisiert. Das BLoC-Pattern für State-Management (vor allem für die Vielzahl asynchroner Flows — Auth, Sync, Subscriptions, In-App-Purchases). GraphQL-Codegen gegen Supabase pg_graphql für typsichere Abfragen und Mutations, dazu Live-Kurse als GraphQL-Subscription. Hive als lokale Cache-Schicht, sodass die App auch im Flugmodus benutzbar bleibt. Sensible Aktionen (Login, Werte einsehen) sind hinter local_auth (FaceID / TouchID) abgesichert.

Dazu kommt ein weniger sichtbarer, aber genauso wichtiger Block: die Build- und Release-Pipeline. Eine Flutter-App auf sechs Plattformen ist sechsmal die Möglichkeit, sich in Signing-Themen zu verlieren. Wir haben das früh in Skripte gegossen — pro Plattform ein klar dokumentierter Pfad von flutter build zu „artefakt in der jeweiligen Store-/Distribution-Pipeline”.

lib/sync/portfolio_sync_bloc.dart dart
// Sync-Bloc: koordiniert Auth-Status, Online-Status und letzten Sync-Stempel.
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()));
  }
});

Tiefe Einblicke

Portfolio-State als versioned Freezed-Union

Alle möglichen Zustände des Portfolios sind als algebraischer Datentyp modelliert — Loading, Loaded, Syncing, Error. Die UI macht ein exhaustive Switch und der Compiler erzwingt, dass jeder Fall behandelt ist. Kein „undefined” mehr im Production-Tracking, kein „warum ist der Loading-Spinner noch da” — der State kann nicht in einer Mischform stecken.

lib/portfolio/bloc/portfolio_state.dart dart
@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 der UI — der Compiler erzwingt jeden Fall.
Widget build(BuildContext context) =>
  state.when(
    loading: () => const Shimmer(),
    loaded: (positions, total, change, sync) =>
        PortfolioView(positions, total, change, sync),
    error: (msg) => ErrorView(msg),
  );

Echtzeit-Kurse via GraphQL-Subscription

pg_graphql in Supabase liefert Realtime-Subscriptions praktisch frei Haus. Die App öffnet pro Watchlist einen WebSocket-Stream und aktualisiert die UI ohne Polling. Bei Verbindungsabbruch greift automatischer Reconnect mit Backoff. Im Hintergrund liegt ein Trigger auf der prices-Tabelle, der bei Schreibungen aus dem Kursdaten-Importer Broadcasts feuert.

lib/prices/repository.dart dart
// GraphQL-Subscription pro 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 cachen Kurse und Positionen lokal. Die UI abonniert direkt den Stream der Box — kommen Updates aus dem Netz, schreibt der Sync-Layer in die Box, und die UI rendert von selbst neu. Kein zusätzlicher BLoC dazwischen, kein Glue-Code. Die App ist im Flugmodus voll bedienbar, mit klarem „last synced X minutes ago”-Indikator.

lib/cache/price_cache.dart dart
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 abonniert direkt: Schreibt der Sync, rendert das Widget neu.
  Stream<Price?> watch(String symbol) =>
      _box
          .watch(key: symbol)
          .map((e) => e.value as Price?)
          .startWith(_box.get(symbol));
}

In-App-Purchases mit Server-side-Receipt-Verifikation

Apples und Googles eingebaute Receipt-Checks reichen für Spielzeugfälle. Bei einer App, deren Premium-Features echtes Geld kosten und Subscription-Modelle (monatlich, jährlich, Family-Plan) bietet, muss die Verifikation serverseitig laufen — sonst kann ein gerooteter Client behaupten, alles freigeschaltet zu sein. Eine Supabase Edge Function nimmt den Receipt entgegen, verifiziert ihn beim jeweiligen Store, schreibt das Ergebnis in subscriptions. Der Client liest den Tier-Status danach von dort, nicht von seinem eigenen Receipt.

supabase/functions/verify-receipt/index.ts ts
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 mit echtem Translator-Workflow

Eine deutsche Finanz-App ist kein Markt für „Wir benutzen Google Translate und fertig”. Plural-Forms („1 Position”, „2 Positionen”, „0 Positionen”), Währungsformatierung pro Locale, Datumsformate — das wird sonst zur Bug-Quelle. gettext mit .pot/.po-Dateien gibt mir alles, was Crowdin oder Weblate brauchen: Strings werden aus dem Code extrahiert, Übersetzer arbeiten in ihrer gewohnten Umgebung, Translation-Memory funktioniert.

lib/l10n/portfoldio.pot text
#: lib/portfolio/widgets/position_count.dart:24
#, dart-format
msgid "{count, plural, one {1 Position} other {{count} Positionen}}"
msgstr ""

#: lib/portfolio/widgets/last_synced.dart:18
#, dart-format
msgid "Letzte Synchronisation vor {minutes} Min."
msgstr ""

#: lib/iap/paywall.dart:42
msgid "Portfoldio Plus freischalten"
msgstr ""

Was zwei Jahre Produktion gezeigt haben

Drei Punkte, an denen ich mir am Anfang nicht sicher war, die sich aber im Betrieb klar entschieden haben:

Web ist die wichtigste Plattform, nicht iOS. Klingt absurd für eine „mobile-first”-App, aber: viele Nutzer kommen über die Web-App rein, probieren sie aus, und installieren erst danach die mobile Version. Die Web-Variante muss daher dieselbe Qualität haben wie die App. Flutter Web kommt 2024+ damit hin, solange man Frame-Budget-bewusst baut — keine Listen mit hunderten gerenderten Items ohne ListView.builder, kein setState auf einer Root-Komponente.

Offline-First ändert das Feedback-Verhalten der App komplett. Wenn der Nutzer nicht einmal auf einen Spinner schaut, sondern sofort die letzten Daten sieht und der Refresh im Hintergrund passiert, fühlt sich das wie native iOS-Apps an. Das war von Beginn an Architekturprämisse — und ist im Nachhinein der wichtigste Punkt, weshalb Nutzer länger bleiben.

Subscription-Logik ist 5 % der Codebase und 30 % der Bug-Reports. Server-side-Verifikation, Family-Plan-Sharing, Restore-Purchase, Subscription-Wechsel innerhalb des Tiers, plattformübergreifende Abos (jemand kauft auf iOS, will aber im Web nutzen) — jeder dieser Fälle hat seine eigenen Edge-Cases. Lessons-Learned: testbare reine Funktionen für „aus diesem Receipt folgt dieser Tier”, dazu eine kleine Matrix an integration tests, die die häufigsten Pfade abdeckt.

05 Entscheidungen

Schlüssel-Entscheidungen

01

Flutter statt nativer Doppel-Implementierung

Eine Codebasis für iOS, Android, Web und Desktop. Bei einer Solo- Entwicklung war das nicht-verhandelbar — zwei native Apps parallel zu pflegen hätte das Projekt erstickt. Flutter ist 2023+ produktionsreif für Finanz-UIs, solange das Design diszipliniert geführt wird.

02

Supabase als kompletter Backend-Stack

Auth (E-Mail, Google, Apple), Postgres mit pg_graphql, Realtime für Live-Kurse, Storage für Belege. Statt drei verschiedene Cloud-Services zu verkabeln, läuft alles unter einer Konsole. Self-hosting bleibt möglich, falls die Hosted-Tier mal nicht mehr passt — wichtig, wenn die App finanzielle Daten verwaltet.

03

BLoC + injectable statt Provider/Riverpod

BLoC zwingt zur sauberen Trennung von State und Events. Bei vielen Cross-Cutting-Flows (Auth wirkt auf Sync, Sync wirkt auf Subscription-Tier, IAP wirkt auf Features) zahlt sich die Struktur aus. `injectable` generiert das Dependency-Graph zur Compile-Zeit, sodass fehlende Bindings nicht erst zur Laufzeit auffallen.

04

Hive für Offline-First

Jede Position, jeder Kurs, jede Transaktion liegt lokal vor — die App startet ohne Netz und zeigt sofort den letzten bekannten Stand. Sync passiert im Hintergrund, mit Last-Write-Wins-Heuristik für Konflikte und einem klaren „last synced X minutes ago"-Indikator als Sicherheit für den Nutzer.

05

GraphQL Codegen über pg_graphql

pg_graphql exponiert das Postgres-Schema als GraphQL ohne separates Backend. Der Codegen erzeugt Dart-Typen für jede Query und Subscription — eine Schemaänderung wird zum Compile-Fehler. Kein „silent drift" zwischen DB und App, kein manuelles JSON-Mapping.

06

gettext (.pot/.po) für i18n

Übersetzer arbeiten in Crowdin/Weblate, der Build extrahiert Strings nach `.pot` und integriert übersetzte `.po`-Dateien automatisch. Kein „Übersetzer öffnet die Code-Datei und ändert Strings". Funktioniert auch mit pluralisierten Formen sauber, was bei Finanz-Apps („1 Position", „2 Positionen") nicht ignorierbar ist.

07

Biometrische Authentifizierung für sensible Aktionen

`local_auth` (FaceID / TouchID) schützt nicht die ganze App, sondern gezielt Aktionen: Login wiederherstellen, Werte aufdecken, Trade eintragen. So bleibt die App im normalen Gebrauch schnell, ohne dass eine Aufnahme über Bekanntenkreis-Risiken („mein Kind hat im Bus auf mein Handy geguckt") fehlt.

07 Was hängenbleibt

  • 01 Flutter ist 2023+ produktionsreif für Finanz-Apps — vorausgesetzt, das Design-System bleibt diszipliniert. Ein cooles Custom-Widget mehr kostet auf Web ein paar Frames.
  • 02 Supabase + GraphQL Codegen + reactive_forms_annotations: drei Codegen- Pipelines, die zusammen den Setup-Aufwand in der ersten Woche zurückzahlen.
  • 03 BLoC ist umständlicher als Provider/Riverpod — und genau das ist bei Apps mit vielen Cross-Cutting-Flows (Auth × Sync × IAP × Subscriptions) der Vorteil. „Boilerplate" hier ist „expliziter Kontrollfluss" dort.
  • 04 In-App-Purchases sind weniger schmerzhaft als ihr Ruf. Apples und Googles Doku haben sich in den letzten Jahren überraschend gut entwickelt, und Server-side-Receipt-Verifikation gehört dringend dazu — das machen Open-Source-Plugins nicht alle automatisch.
  • 05 gettext .pot/.po ist das richtige Format, weil Übersetzer dafür echte Tools haben (Crowdin, Weblate). „Wir versenden ein YAML"-Pipelines verlieren zuverlässig Strings.
  • 06 Offline-First ist nicht „kleine Verbesserung" — es ist der Unterschied zwischen „die App lädt drei Sekunden" und „die App ist sofort da". Im täglichen Gebrauch macht das den Eindruck zwischen „schick" und „echt flüssig" aus.
Kontakt

Klingt vertraut?

Schreiben Sie mir kurz, worum es geht. Ich melde mich innerhalb von 24 Stunden mit einer ehrlichen Einschätzung — auch wenn ich nicht der richtige Partner bin.