Indie Devlog #01: Building a Multi-App Flutter Design System

Saturday January 17 2026·3 min read

Every indie app starts with a few hardcoded colors. A teal AppBar, some Poppins text styles, a custom button that looks nice. Then you build a second app and suddenly you have two divergent color palettes, two sets of button widgets, and duplicate code that you maintain in parallel.

That is where I found myself after building Fiscify. TipFolio needed its own look — Figtree typography, different accent colors, a distinct personality. Copying the design system was tempting but the thought of maintaining two copies made me cringe. So I extracted microbyte_design.


The problem with "one size fits all"

Design systems in Flutter usually go one of two ways: a hardcoded theme that works for one app, or a Material ThemeExtension that is technically reusable but offers no guardrails. Neither scales across a portfolio.

Fiscify used a custom AppLightTheme class that referenced static color tokens — AppColors.primary, AppColors.surface. These were fine for a single app but impossible to share. TipFolio could import the package and override colors, but static references meant every component had to be re-checked for hardcoded values.

The solution was to invert the dependency: instead of the design system pulling colors from a static class, the app would inject its identity into the design system.


Token interfaces: the contract between apps

The core abstraction is UiColors — an abstract class that defines every color an app needs:

abstract class UiColors { // Primary Color get primary; Color get onPrimary; Color get primaryContainer; Color get onPrimaryContainer; // Surface Color get surfaceBackground; Color get surfaceContainer; Color get onSurface; // Semantic Color get error; Color get onError; Color get success; Color get warning; // Text Color get textPrimary; Color get textSecondary; Color get textDisabled; }

Each app implements this interface. Fiscify has its teal-based palette, TipFolio has its brand colors, and the design system never knows about either. It just calls context.colors.primary and gets the right value at runtime.

// Fiscify config class FiscifyColors implements UiColors { @override Color get primary => const Color(0xFF00897B); // teal } // TipFolio config class TipfolioColors implements UiColors { @override Color get primary => const Color(0xFF6C63FF); // purple }

Config drives everything

The MicrobyteDesignConfig ties it all together. It holds light and dark schemes, each containing colors, typography, spacing, radii, shadows, and component defaults.

class MicrobyteDesignConfig { final MicrobyteDesignScheme light; final MicrobyteDesignScheme dark; final List<ThemeExtension<dynamic>> Function(MicrobyteDesignScheme)? extraExtensions; } class MicrobyteDesignScheme { final UiColors colors; final UiTypography typography; final UiSpacing spacing; final UiBorderRadius radii; final UiShadows shadows; final UiComponents components; }

The MicrobyteDesignTheme.buildThemeData() method consumes this config and produces a proper Flutter ThemeData with everything wired up:

MaterialApp( theme: MicrobyteDesignTheme.buildThemeData( MicrobyteDesignPreset.fiscify, brightness: Brightness.light, ), darkTheme: MicrobyteDesignTheme.buildThemeData( MicrobyteDesignPreset.fiscify, brightness: Brightness.dark, ), home: const MyHome(), )

Extensions without forking

TipFolio needed marketing-specific colors on its landing page — colors that had no place in UiColors. Rather than forking the package, the config accepts extraExtensions:

MicrobyteDesignTheme.buildThemeData( tipfolioDesignConfig(), brightness: Brightness.light, )

Where tipfolioDesignConfig() returns the standard tokens plus a TipFolioThemeExtension bundled through extraExtensions. The design system registers them on ThemeData automatically. The app gets exactly one ThemeData with everything in one place.


DesignContext: the runtime resolver

Once widgets are inside a themed MaterialApp, they resolve tokens through DesignContext:

context.colors // UiColors for current brightness context.typography // UiTypography context.radii // UiBorderRadius context.components // UiComponents defaults

Under the hood this reads UiDesignTheme.of(context) — a ThemeExtension that the build method registered. No static references, no hardcoded fallbacks. If the config is missing, it falls back to the Fiscify preset with a debug print so you catch it during development.


What this enabled

With the design system extracted, I can spin up a new app and have a full branded UI day one — buttons, text fields, cards, bottom sheets, skeleton loaders — all styled to the new brand by writing one config file. The component library grows across apps: if Fiscify needs a new UiChip variant, TipFolio and Voxoap get it for free.

The per-app presets are small enough to live in the app repo:

tip-tracker/apps/mobile/lib/design_system/ config/tipfolio_design_config.dart # 30 lines theme/tipfolio_app_theme.dart # 20 lines

Instead of maintaining 5,000 lines of duplicated theme code per app, I maintain 800 shared lines plus 50 lines of config per app.


What I would do differently

One thing I wish I had done earlier: use context.colors from day one. The first version of the design system had both context.colors and static UiColors.lightColors. Components drifted toward whichever was convenient. I eventually deprecated the statics, but the migration took three rounds of cleanup.

If you are building a Flutter design system for multiple apps, start with the context-based resolver. The extra indirection pays for itself the moment you add a second brand.


For suggestions and queries, just contact me.

Zuhaib Ahmad © 2026