HOCO B2B
Mobile (Flutter)

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 groupFlutterNotes
Semantic colours (primary, surface, text-secondary, border, focus…)ColorScheme built explicitly from tokens (never ColorScheme.fromSeed, which shifts tones) + HocoColors for roles Material lacksWhatsApp green, price, promo accent
Status tones (8 × bg, fg, border, dot)HocoStatusColorsEvery enum value maps to a tone generated from enums.json, so StatusPill(OrderStatus.validated) needs no colour choice (status tones)
Spacing, radii, elevationHocoSpacing, HocoRadii, HocoShadows + component themes (CardThemeData, FilledButtonThemeData, InputDecorationThemeData, BottomSheetThemeData…)Component themes carry 80 % of the look; widgets add the rest
TypographyTextTheme mapped from the mobile scale + HocoTextStyles (display, headline, titleLg … labelSm, priceMd, priceLg, numberKpi) with tabular figures (FontFeature.tabularFigures()) for money and quantitiesFonts bundled as assets: Montserrat (HOCO PRO), IBM Plex Sans Arabic as fontFamilyFallback and as the family in Arabic (typography) — no runtime font download
Iconslucide_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_svg24 / 20 / 16 sizes; icon buttons always 48 dp targets
MotionHocoMotion durations and curves; MediaQuery.disableAnimations respectedLive-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) (a null price renders the guest lock), AvailabilityIndicator(Availability.inStock).
  • Labels come from hoco_l10n; widgets never embed French text.
  • hoco_catalog composes these into product cards, cart lines and filter sheets; apps compose screens. No screen-specific widget lives in hoco_ui.

The components that carry business rules — built and tested first:

Inventory componentWidgetBehaviour that must hold
Price blockPriceBlockprice == 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 indicatorAvailabilityIndicatorExactly En stock (success) or En rupture (danger); never a quantity (STK-07)
Quantity stepperHocoQuantityStepperMin, 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 cardsCustomerTypeCardsThree radio cards with one line each; error when nothing chosen; read-only variant for RA-28
Product cardProductCard.grid / .listRow / .miniGuest lock, out of stock (add disabled), in cart (stepper), promo badge, thumbnail decoded at display size
Order card and progress trackerOrderCard, OrderProgressTrackerFive steps Envoyée → Validée → En préparation → Expédiée → Livrée; En attente branch; animated highlight when a live update lands
Live indicatorLiveIndicatorConnected, reconnecting, paused states from the SSE controller
Return / after-sales line pickerReturnLinePickerLines of one delivered order; quantity capped by delivered minus already returned; photo-required error
Visit rowVisitRowSequence badge, slot, distance, status pill, late and priority flags, pending-sync marker
GPS validation bannerGpsValidationBannerAcquiring (live accuracy), confirmed, out of tolerance with distance, weak accuracy, GPS off
Sync status badgeSyncStatusBadgeSynchronisé, En attente (n), Erreur (n), Hors ligne; tap opens DA-27
Photo capture tilePhotoCaptureTileQueued, uploading %, uploaded, failed with retry, required error
WhatsApp buttonWhatsAppSupportButtonFAB variant on every reseller screen; resolved, closed hours, region unknown (opens the picker), offline
Sign-up promptGuestPromptSheetThe RA-41 sheet used by locked tabs and buttons
Filter sheet partsCategoryTreeFilter, BrandFilter, PriceRangeFilter, AttributeChipsFilter, ToggleFilter, SortListBuilt from facet data only; disabled options show (0); price range hidden for guests
QR cardInvitationQrCard (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).

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, ar to 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 to hoco_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 — never left / right. A CI grep fails on EdgeInsets.only(left:, EdgeInsets.only(right:, Alignment.centerLeft, TextAlign.left outside generated files.
  • Directional icons (back, chevrons, progress arrows) use matchTextDirection: true or 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 LTR Directionality (or Unicode isolates) inside Arabic text.
  • Strings leave 30 % expansion room; no text baked into images.
  • Golden tests of the key components run in ar as well as fr.

Text scaling and accessibility

RuleImplementationCheck
Layouts hold at 130 % text sizeRespect MediaQuery.textScalerOf; no fixed heights around text; clamp at 2.0 with MediaQuery.withClampedTextScaling(maxScaleFactor: 2.0) at the app rootGoldens at 1.0 and 1.3; manual pass at the largest system size in MOB-17
Touch targets 48 dpMaterialTapTargetSize.padded; icon buttons 48 dp whatever the glyphmeetsGuideline(androidTapTargetGuideline) in widget tests
Contrast 4.5:1 text, 3:1 boundariesTokens chosen by Akram meet it; no ad-hoc coloursmeetsGuideline(textContrastGuideline)
Screen-reader labelstooltip or Semantics(label:) on every icon button; MergeSemantics on list rows; decorative images excluded; status pills read their labelmeetsGuideline(labeledTapTargetGuideline); TalkBack pass on the critical flows
Colour never the only signalPills carry text; map markers carry numbers; errors carry text and iconReview checklist
Live changes announcedOrder status updates and sync results inside Semantics(liveRegion: true)TalkBack pass
Reduced motionMediaQuery.disableAnimations → no highlight animation, static skeletonsGallery 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 matchesGoldenFile and generate goldens only in the Linux CI container.

On this page