Architecture

Design as data: how one Flutter codebase renders fifty résumé designs

By Zuber Amin7 min read

When I started CV Spark, the obvious plan was one screen per template. That plan works for three templates. At ten, a bug fix in the education section means ten edits. At fifty, the templates drift apart and you stop trusting any of them.

The fix was to stop thinking of a template as a screen and start thinking of it as a description.

Two models that never meet

CV Spark has two core types. CVModel holds a person's content: name, roles, schools, skills. CVTemplate holds a look: colors, heading and body fonts, a layout, a skill-visualisation style, a photo-frame shape and a background pattern. Neither type imports the other.

class CVTemplate {
  final String headingFont;      // a Google Font name
  final String bodyFont;
  final LayoutType layout;       // 42 values
  final ProgressStyle skillStyle; // 44 values
  final PhotoFrameShape photoFrame;
  final PatternType pattern;
  // …colors, spacing, radius. Nothing about a person.
}

Because the template knows nothing about content, any template can render anyone's résumé. Switching designs is a lookup, not a migration.

Enums that point at real implementations

The risky part of "design as data" is that it collapses into one generic layout with fifty colour schemes. To avoid that, each enum value maps to a real, separate implementation. The skill visualiser reads skillStyle and switches between painters: a segmented circular arc, a DNA helix, a thermometer fill, a moon-phase row. Each one is drawn on its own canvas. None is a recoloured copy of another.

The data decides which design. The code decides what that design is. Keep those jobs apart and both stay small.

Overrides that respect the designer

People want to change fonts and colours. Designers want their template to survive that. Every user override in CV Spark defaults to null, which means "use the template". Small resolver methods decide each field:

String resolveFont(CVTemplate t) => fontFamilyOverride ?? t.headingFont;
Color  resolveHeaderColor(CVTemplate t) => headerColorOverride ?? t.primaryColor;

The catch is copyWith. In Dart, copyWith(font: null) normally means "keep the old value", so a user can never clear an override. CV Spark uses a private sentinel object as the default argument. If the argument is the sentinel, nothing changed. If it is null, the user cleared it on purpose.

What it bought me

  • A fifty-first template is a new data entry. No new form, no new save logic.
  • Fixing a section once fixes it everywhere.
  • The PDF exporter reads the same template, so preview and export match.

If you are building anything with "many looks, one content", for example themes, report styles or invoice designs, start by drawing the line between the two models. Everything else gets easier after that.

Related project: CV Spark: Fifty résumé designs, fully offline, on the phone already in your hand.