Skip to main content

Stack overview

LayerTechnology
UI frameworkFlutter 3.x, Material 3
State managementflutter_riverpod
Navigationgo_router with StatefulShellRoute
Local DBsqflite_sqlcipher (encrypted SQLite)
Chartssyncfusion_flutter_charts + fl_chart
OCRgoogle_ml_kit (on-device)
NotificationsNative Kotlin + platform channel
AuthSupabase Flutter SDK

The app uses a StatefulShellRoute.indexedStack for the 5 bottom-nav tabs. Each branch has its own GlobalKey<NavigatorState> — tab state is preserved when switching between tabs. Overlay routes use parentNavigatorKey: _rootNavigatorKey to render on top of the shell (above the bottom nav bar). The AI chat, add/edit screens, and settings are all overlays.

Floating pill navigation

The bottom navigation is a custom floating glass pill — not Flutter’s BottomNavigationBar. It uses BackdropFilter blur, a small mint dot for the active indicator, and HapticFeedback.selectionClick() on each tap.
// Active indicator: small green dot below icon
if (isSelected)
    Container(
        width: 4, height: 4,
        decoration: BoxDecoration(
            color: VantageColors.growth,  // #10B981
            shape: BoxShape.circle,
        ),
    )

Riverpod provider graph

Providers are structured in layers. Lower layers feed upper layers — no circular dependencies.
Services (singletons)
├── supabaseServiceProvider        → SupabaseService
├── marketDataServiceProvider      → MarketDataService
├── aiServiceProvider              → AiService
└── syncServiceProvider            → SyncService

Data providers (FutureProvider — Supabase queries)
├── accountsProvider               → List<Account>
├── transactionsProvider           → List<Transaction> (current month)
├── holdingsProvider               → List<Holding>
├── budgetsProvider                → List<Budget>
├── debtSummaryProvider            → DebtSummary
└── insightsProvider               → List<Insight> (undismissed)

Market providers (FutureProvider — FastAPI calls)
├── portfolioFxRatesProvider       → Map<String, double>
├── holdingsWithPricesProvider     → List<HoldingWithPrice>
├── portfolioCostBasisProvider     → double
├── portfolioMarketValueProvider   → double
├── portfolioReturnProvider        → double
└── portfolioSparklineProvider     → List<double> (14 days)

Computed providers
├── dashboardSummaryProvider       → DashboardSummary (net worth, spend, debts)
├── monthlyCashflowProvider        → List<MonthlyCashflow> (6 months)
├── spendByCategoryProvider        → Map<String, double>
└── assetHistoryProvider           → FutureProvider.family (symbol, period)

UI state
├── themeModeProvider              → StateNotifier<ThemeMode>
├── currencyProvider               → StateProvider<String>
└── chatProvider                   → StateNotifier<ChatState>

Provider patterns

FutureProvider — used for all async data fetches:
final transactionsProvider = FutureProvider<List<Transaction>>((ref) async {
    final supabase = ref.watch(supabaseServiceProvider);
    final userId = supabase.currentUserId;
    final startOfMonth = DateTime(DateTime.now().year, DateTime.now().month, 1);
    return supabase.getTransactions(userId, since: startOfMonth);
});
FutureProvider.family — for parameterised fetches:
final assetHistoryProvider = FutureProvider.family<List<OhlcvPoint>, AssetHistoryParams>(
    (ref, params) async {
        final service = ref.watch(marketDataServiceProvider);
        return service.getHistory(params.symbol, params.period, params.assetType);
    },
);
StateNotifier — for mutable UI state:
class ChatNotifier extends StateNotifier<ChatState> {
    ChatNotifier(this._aiService) : super(ChatState.initial());

    Future<void> sendMessage(String text) async {
        state = state.copyWith(isLoading: true);
        await for (final event in _aiService.streamAsk(text, state.history)) {
            state = _applyEvent(state, event);
        }
    }
}
AsyncValue — all screens use when() to handle loading/error/data states — no raw null checks or loading booleans in UI code:
ref.watch(transactionsProvider).when(
    loading: () => const SkeletonList(),
    error: (e, _) => ErrorCard(message: e.toString()),
    data: (txns) => TransactionList(transactions: txns),
)

Theme system

All colors, typography, and spacing are defined in lib/core/theme/:

Colors — vantage_colors.dart

abstract final class VantageColors {
    // Semantic accents (shared light + dark)
    static const Color growth  = Color(0xFF10B981); // Mint — gains, positive
    static const Color warning = Color(0xFFF59E0B); // Amber — alerts
    static const Color info    = Color(0xFF3B82F6); // Blue — informational
    static const Color error   = Color(0xFFEF4444); // Red — errors only

    // Dark mode (deep navy — Revolut/Mercury style)
    static const Color darkBackground        = Color(0xFF0A0B0E); // scaffold
    static const Color darkSurface           = Color(0xFF131418); // cards
    static const Color darkSurfaceElevated   = Color(0xFF1C1E26); // elevated cards
    static const Color darkBorder            = Color(0xFF252830);
    static const Color darkTextPrimary       = Color(0xFFF0F2F5);
    static const Color darkTextSecondary     = Color(0xFF6B7280);

    // Light mode
    static const Color lightBackground       = Color(0xFFF1F5F9);
    static const Color lightSurface          = Color(0xFFFFFFFF);
    static const Color lightTextPrimary      = Color(0xFF0F172A);
    static const Color lightTextSecondary    = Color(0xFF64748B);
}

Typography — vantage_typography.dart

Two font families, never mixed up:
FontUsage
InterAll UI text: headings, labels, body, buttons
JetBrains MonoAll financial numbers: prices, amounts, percentages, tickers
// UI text
static TextStyle headline(BuildContext context) =>
    GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w700);

// Financial numbers
static TextStyle amount(BuildContext context) =>
    GoogleFonts.jetBrainsMono(fontSize: 28, fontWeight: FontWeight.w600);

Cards vs glass overlays

Standard cards (VantageCard): 12px radius, darkSurface background, subtle shadow. No blur. Glass overlays (GlassOverlay): BackdropFilter with ImageFilter.blur(sigmaX: 20, sigmaY: 20), semi-transparent background. Used only for the AI chat overlay and modal sheets. Blur is never applied to content cards — it’s reserved for overlays to maintain visual hierarchy.

Spacing — vantage_spacing.dart

4px base unit:
static const double xs  = 4;
static const double sm  = 8;
static const double md  = 16;
static const double lg  = 24;
static const double xl  = 32;
static const double xxl = 48;

App lifecycle

VantageApp is a ConsumerStatefulWidget that implements WidgetsBindingObserver:
void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused ||
        state == AppLifecycleState.hidden) {
        _lastPausedAt = DateTime.now();
    }

    if (state == AppLifecycleState.resumed) {
        // Biometric lock check
        final elapsed = DateTime.now().difference(_lastPausedAt).inSeconds;
        if (elapsed > 30 && _biometricEnabled) {
            _router.go('/lock');
            return;
        }

        // Refresh all providers
        _prefetchProviders();

        // Drain Android notification queue
        NotificationService.drainQueue();

        // Sync local → Supabase
        SyncService.syncAll();

        // Update home screen widgets
        WidgetService.updateAll();
    }
}

Agent widget renderer

The AI chat renders native Flutter widgets from JSON responses. widget_renderer.dart maps the type field to a widget:
Widget buildWidget(Map<String, dynamic> json) => switch (json['type']) {
    'chart'        => AgentChart(data: json),
    'card'         => AgentSummaryCard(data: json),
    'alert'        => AgentAlertCard(data: json),
    'multi_widget' => Column(children: [
        for (final w in json['widgets']) buildWidget(w)
    ]),
    _ => const SizedBox.shrink(),
};
Chart types map to Syncfusion or fl_chart widgets:
  • barSfCartesianChart with bar series
  • lineSfCartesianChart with line series
  • pieSfCircularChart
  • sparklineSparklineChart (custom widget)