
Indie Devlog #01: Building a Multi-App Flutter Design System
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.
