Design system in Flutter
How hoco_ui implements Akram's design system: token JSON to generated Dart, ThemeData and ThemeExtensions, the Flutter mapping of the mobile component inventory, the Widgetbook gallery for design QA, RTL readiness, text scaling, accessibility and golden tests.
hoco_ui is the Flutter half of the design system: the same tokens, the same component names, the same states. Akram's page is authoritative for what each component looks like and when it is used; this page is how it is built in Flutter (MOB-04, after Akram's day-2 tokens and day-3 mobile components — DES-02, DES-03).
From tokens to theme
Pipeline (token delivery): Claude Design tokens → hoco.tokens.json (W3C DTCG format: $type, $value, {alias} references) → copied into packages/hoco_ui/tokens/ → melos run tokens runs tool/gen_tokens.dart → lib/src/tokens/hoco_tokens.g.dart (committed) → HocoTheme.light() used by both apps.
The generator is a plain Dart script (no build_runner): it resolves aliases, converts px dimensions to logical pixels, colours to Color(0xAARRGGBB), and fails on an unknown $type or an unresolved alias, so a broken export never reaches the apps. Every token file change is a PR labelled design-qa with before/after gallery screenshots.
// hoco_tokens.g.dart — generated, do not edit
abstract final class HocoPalette {
static const blue600 = Color(0xFF1D4ED8);
static const neutral900 = Color(0xFF0B1B3F);
// …
}
abstract final class HocoSpace {
static const s1 = 4.0;
static const s2 = 8.0;
static const s3 = 12.0;
static const s4 = 16.0;
static const s6 = 24.0;
// …
}Tokens become ThemeData (Material 3) plus one ThemeExtension per group, read through BuildContext extensions:
enum StatusTone { neutral, info, progress, success, warning, danger, brand, muted }
@immutable
class ToneColors {
const ToneColors({required this.bg, required this.fg, required this.border, required this.dot});
final Color bg;
final Color fg;
final Color border;
final Color dot;
static ToneColors lerp(ToneColors a, ToneColors b, double t) => ToneColors(
bg: Color.lerp(a.bg, b.bg, t)!,
fg: Color.lerp(a.fg, b.fg, t)!,
border: Color.lerp(a.border, b.border, t)!,
dot: Color.lerp(a.dot, b.dot, t)!,
);
}
@immutable
class HocoStatusColors extends ThemeExtension<HocoStatusColors> {
const HocoStatusColors(this.tones);
final Map<StatusTone, ToneColors> tones;
ToneColors of(StatusTone tone) => tones[tone]!;
@override
HocoStatusColors copyWith({Map<StatusTone, ToneColors>? tones}) => HocoStatusColors(tones ?? this.tones);
@override
HocoStatusColors lerp(HocoStatusColors? other, double t) => other == null
? this
: HocoStatusColors({for (final k in StatusTone.values) k: ToneColors.lerp(of(k), other.of(k), t)});
}
extension HocoThemeX on BuildContext {
HocoColors get hocoColors => Theme.of(this).extension<HocoColors>()!;
HocoStatusColors get hocoStatus => Theme.of(this).extension<HocoStatusColors>()!;
HocoSpacing get hocoSpace => Theme.of(this).extension<HocoSpacing>()!;
HocoTextStyles get hocoText => Theme.of(this).extension<HocoTextStyles>()!;
}| Token group | Flutter | Notes |
|---|---|---|
Semantic colours (primary, surface, text-secondary, border, focus…) | ColorScheme built explicitly from tokens (never ColorScheme.fromSeed, which shifts tones) + HocoColors for roles Material lacks | WhatsApp green, price, promo accent |
| Status tones (8 × bg, fg, border, dot) | HocoStatusColors | Every enum value maps to a tone generated from enums.json, so StatusPill(OrderStatus.validated) needs no colour choice (status tones) |
| Spacing, radii, elevation | HocoSpacing, HocoRadii, HocoShadows + component themes (CardThemeData, FilledButtonThemeData, InputDecorationThemeData, BottomSheetThemeData…) | Component themes carry 80 % of the look; widgets add the rest |
| Typography | TextTheme mapped from the mobile scale + HocoTextStyles (display, headline, titleLg … labelSm, priceMd, priceLg, numberKpi) with tabular figures (FontFeature.tabularFigures()) for money and quantities | Fonts bundled as assets: Montserrat (HOCO PRO), IBM Plex Sans Arabic as fontFamilyFallback and as the family in Arabic (typography) — no runtime font download |
| Icons | lucide_icons_flutter (same glyph names as lucide-react on the web) + custom SVGs (QR invite, GPS check-in, route, after-sales, price lock) via flutter_svg | 24 / 20 / 16 sizes; icon buttons always 48 dp targets |
| Motion | HocoMotion durations and curves; MediaQuery.disableAnimations respected | Live-update highlight on order cards |
Light theme only in V1 (Assumption — the design system defines no dark mode for the apps); the theme is built so a dark variant is a second token set, not a refactor.
Components
The inventory is the mobile components table. Rules for the Flutter side:
- One widget per inventory component, same name prefixed
Hoco(HocoButton,HocoQuantityStepper), variants as named constructors (HocoButton.primary,.secondary,.tertiary,.danger,.whatsapp), every state reachable through parameters so the gallery can show it. - Widgets take domain values, not strings to colour:
StatusPill.order(OrderStatus.validated),PriceBlock(price: product.price)(anullprice renders the guest lock),AvailabilityIndicator(Availability.inStock). - Labels come from
hoco_l10n; widgets never embed French text. hoco_catalogcomposes these into product cards, cart lines and filter sheets; apps compose screens. No screen-specific widget lives inhoco_ui.
The components that carry business rules — built and tested first:
| Inventory component | Widget | Behaviour that must hold |
|---|---|---|
| Price block | PriceBlock | price == null → lock icon and « Créez un compte pour voir les prix » (guest, AUTH-10); TTC large by setting; promo shows the struck original and −20 %; tabular figures; amounts formatted from centimes |
| Availability indicator | AvailabilityIndicator | Exactly En stock (success) or En rupture (danger); never a quantity (STK-07) |
| Quantity stepper | HocoQuantityStepper | Min, step and max from the product's order limits (not stock); at min in the cart the minus becomes delete; long-press repeats; emits the final value after 400 ms idle |
| Customer-type cards | CustomerTypeCards | Three radio cards with one line each; error when nothing chosen; read-only variant for RA-28 |
| Product card | ProductCard.grid / .listRow / .mini | Guest lock, out of stock (add disabled), in cart (stepper), promo badge, thumbnail decoded at display size |
| Order card and progress tracker | OrderCard, OrderProgressTracker | Five steps Envoyée → Validée → En préparation → Expédiée → Livrée; En attente branch; animated highlight when a live update lands |
| Live indicator | LiveIndicator | Connected, reconnecting, paused states from the SSE controller |
| Return / after-sales line picker | ReturnLinePicker | Lines of one delivered order; quantity capped by delivered minus already returned; photo-required error |
| Visit row | VisitRow | Sequence badge, slot, distance, status pill, late and priority flags, pending-sync marker |
| GPS validation banner | GpsValidationBanner | Acquiring (live accuracy), confirmed, out of tolerance with distance, weak accuracy, GPS off |
| Sync status badge | SyncStatusBadge | Synchronisé, En attente (n), Erreur (n), Hors ligne; tap opens DA-27 |
| Photo capture tile | PhotoCaptureTile | Queued, uploading %, uploaded, failed with retry, required error |
| WhatsApp button | WhatsAppSupportButton | FAB variant on every reseller screen; resolved, closed hours, region unknown (opens the picker), offline |
| Sign-up prompt | GuestPromptSheet | The RA-41 sheet used by locked tabs and buttons |
| Filter sheet parts | CategoryTreeFilter, BrandFilter, PriceRangeFilter, AttributeChipsFilter, ToggleFilter, SortList | Built from facet data only; disabled options show (0); price range hidden for guests |
| QR card | InvitationQrCard (qr_flutter) | 200 dp minimum, quiet zone, code displayed AB12-CD34, screen brightness raised while shown |
Everything else (app bars, bottom navigation, sheets, dialogs, snackbars, alerts, empty/error/skeleton states, list rows, timeline, message bubbles) follows Material 3 widgets themed by tokens, wrapped only where the inventory adds behaviour (skeleton after 300 ms, one snackbar at a time).
Widget gallery for design QA
Recommendation: Widgetbook in apps/gallery, with code-generated use cases (widgetbook_annotation, widgetbook_generator).
@widgetbook.UseCase(name: 'Guest (locked)', type: PriceBlock)
Widget priceBlockGuest(BuildContext context) => const PriceBlock(price: null);
@widgetbook.UseCase(name: 'Promotion', type: PriceBlock)
Widget priceBlockPromo(BuildContext context) => PriceBlock(
price: Price(ht: const Money(105042), ttc: const Money(125000), vatRateBasisPoints: 1900),
original: const Money(156300),
);- Addons: theme, locale (
fr,arto check RTL,en), text scale (1.0, 1.3, 2.0), device frames of the reference phones. - Every component of the inventory has a use case per variant and per extra state before its PR is merged; Akram reviews the gallery instead of hunting states in the apps (design QA DQA-02, DQA-03).
- Distribution: an APK through Firebase App Distribution (group
design) on each change tohoco_ui; optionally a web build of the gallery for desktop review.
RTL readiness
Arabic is prepared, not shipped, in V1 (NFR-06, RTL rules). The code must flip correctly the day ar is enabled:
EdgeInsetsDirectional,AlignmentDirectional,BorderRadiusDirectional,PositionedDirectional,TextAlign.start/end— neverleft/right. A CI grep fails onEdgeInsets.only(left:,EdgeInsets.only(right:,Alignment.centerLeft,TextAlign.leftoutside generated files.- Directional icons (back, chevrons, progress arrows) use
matchTextDirection: trueor the adaptive variants; check marks, clock, play, QR and logos do not mirror. - Phone numbers, codes (
AB12-CD34,CMD-2026-000158), amounts and coordinates are wrapped in an LTRDirectionality(or Unicode isolates) inside Arabic text. - Strings leave 30 % expansion room; no text baked into images.
- Golden tests of the key components run in
aras well asfr.
Text scaling and accessibility
| Rule | Implementation | Check |
|---|---|---|
| Layouts hold at 130 % text size | Respect MediaQuery.textScalerOf; no fixed heights around text; clamp at 2.0 with MediaQuery.withClampedTextScaling(maxScaleFactor: 2.0) at the app root | Goldens at 1.0 and 1.3; manual pass at the largest system size in MOB-17 |
| Touch targets 48 dp | MaterialTapTargetSize.padded; icon buttons 48 dp whatever the glyph | meetsGuideline(androidTapTargetGuideline) in widget tests |
| Contrast 4.5:1 text, 3:1 boundaries | Tokens chosen by Akram meet it; no ad-hoc colours | meetsGuideline(textContrastGuideline) |
| Screen-reader labels | tooltip or Semantics(label:) on every icon button; MergeSemantics on list rows; decorative images excluded; status pills read their label | meetsGuideline(labeledTapTargetGuideline); TalkBack pass on the critical flows |
| Colour never the only signal | Pills carry text; map markers carry numbers; errors carry text and icon | Review checklist |
| Live changes announced | Order status updates and sync results inside Semantics(liveRegion: true) | TalkBack pass |
| Reduced motion | MediaQuery.disableAnimations → no highlight animation, static skeletons | Gallery with animations disabled |
Golden tests
Recommendation: alchemist — CI goldens rendered with blocked text are stable across operating systems, so goldens written on Windows pass on the Linux runner; platform goldens with real fonts are produced locally for visual review.
void main() {
goldenTest(
'PriceBlock states',
fileName: 'price_block',
builder: () => GoldenTestGroup(
columns: 2,
children: [
GoldenTestScenario(name: 'signed in', child: const PriceBlock(price: samplePrice)),
GoldenTestScenario(name: 'guest', child: const PriceBlock(price: null)),
GoldenTestScenario(name: 'promo', child: const PriceBlock(price: samplePrice, original: Money(156300))),
GoldenTestScenario(
name: 'text 130 %',
child: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(1.3)),
child: const PriceBlock(price: samplePrice),
),
),
],
),
);
}- Every component in the table above has goldens for its variants, the guest/locked state where relevant,
ar, and text scale 1.3. - Goldens are updated only in a PR that says why (
flutter test --update-goldens --tags golden), with the image diff attached for Akram. - If alchemist is not kept, use plain
matchesGoldenFileand generate goldens only in the Linux CI container.
Notifications and deep links
Push in both apps: FCM per flavor, device registration, the payload contract, foreground, background and terminated handling, routing by event, Android channels, App Links for invitation links, Play install referrer attribution and the iOS fallback.
Quality and testing
How the Flutter apps are tested and kept healthy: the test pyramid, integration tests of the critical flows, the GitHub Actions pipeline, the review checklist, performance budgets, crash reporting and the manual device matrix.