Summary
Consolidated theme token system for Flow Mobile. Single source of truth for all AppTheme constants. Branch: edit/mobile-audit-ux-polish.
Canonical Color Tokens
Use these in all new code. Legacy aliases (below) still compile but are deprecated.
// Brand
static const Color electricCoral = Color(0xFFFF5E57); // primary CTA, accent
static const Color electricViolet = Color(0xFF8A2BE2); // brand complement
static const Color mintGreen = Color(0xFF00C896); // success, gamification
static const Color inkBlack = Color(0xFF18181B); // headlines, dark bg
static const Color pureWhite = Color(0xFFFFFFFF);
// Text hierarchy
static const Color textPrimary = Color(0xFF18181B); // Zinc 900
static const Color textSecondary = Color(0xFF52525B); // Zinc 600
static const Color textMuted = Color(0xFF9E9E9E); // captions, placeholders
// Dark mode text
static const Color textPrimaryDark = Color(0xFFFFFFFF);
static const Color textSecondaryDark = Color(0xFFA1A1AA);
// Dividers / surfaces
static const Color dividerLight = Color(0xFFE4E4E7);
// Brand gradient (coral → violet)
static const LinearGradient brandGradient = LinearGradient(
colors: [electricCoral, electricViolet],
);warmCanvas retoned 2026-05-08
warmCanvas was originally #F0EEE9 — a warm off-white with a yellow tint. It clashed with the modern editorial direction, especially in the redesigned event details + crew wizard surfaces. Retoned to #FAFAFA (zinc-50, neutral light gray) at the token level. The token name is kept for back-compat across ~50 call-sites; new code should still prefer pureWhite for primary surfaces.
static const Color warmCanvas = Color(0xFFFAFAFA); // was 0xFFF0EEE9Legacy Aliases (do not use in new code)
static const Color textPrimaryLight = textPrimary; // → textPrimary
static const Color textSecondaryLight = textSecondary; // → textSecondary
static const Color textSecondaryWarm = textMuted; // → textMutedThese were not removed to avoid churn across 147 call-sites. New screens must use canonical names only.
Radius Tokens
| Token | Value | Rule |
|---|---|---|
radius8 | 8.0 | Chips, small elements |
radius12 | 12.0 | Snackbars, small cards |
radius14 | 14.0 | Form card corners |
radius16 | 16.0 | Standard cards |
radius24 | 24.0 | Hero cards, large containers |
radius28 | 28.0 | Bottom sheet tops (matches bottomSheetTheme) |
radiusFull | 9999.0 | Pill buttons, social buttons |
radius32was removed — it had value24.0behind a lying name. All former call-sites migrated toradius24(cards) orradius28(bottom sheets). Never re-add it.
ColorScheme Wiring
colorScheme: ColorScheme(
primary: electricCoral, // main CTAs, brand signature
secondary: electricViolet, // selected chips, highlights
onSecondary: pureWhite,
tertiary: mintGreen, // success accents, gamification
onTertiary: inkBlack,
...
);Widgets automatically affected (no code change required):
chat_screen.darttyping indicator → violetprofile_avatar.dartcolor-from-hash → picks from coral / violet / mint
Page-Title Typography
AppTheme.pageTitleStyle
All screen page titles — navbar tabs AND secondary/pushed screens — must use AppTheme.pageTitleStyle. This is the single source of truth for header typography.
// static final (not const) — GoogleFonts.inter() bakes in the fontFamily path
// so the font is always Inter regardless of DefaultTextStyle context.
static final TextStyle pageTitleStyle = GoogleFonts.inter(
fontSize: 30, // = 22 × 1.35 (FlowBrandLockup height × scale)
fontWeight: FontWeight.w900, // Black — matches official brand-book lockup spec
letterSpacing: -0.6, // = -(30 × 0.02) — brand-book SVG uses letter-spacing="-0.02em"
color: textPrimary,
height: 1.0,
);Why final not const: const TextStyle without a fontFamily inherits font from DefaultTextStyle. Inside SliverAppBar.title, DefaultTextStyle is AppBarTheme.titleTextStyle (Inter). In a plain Row, it resolves differently — causing the visual mismatch. GoogleFonts.inter() always returns a TextStyle with fontFamily: 'packages/google_fonts/Inter' baked in.
Letter-spacing formula: brand-book lockup SVGs specify letter-spacing="-0.02em". In Flutter, letterSpacing is logical pixels (not em), so the conversion is -(fontSize × 0.02). For pageTitleStyle at 30px → -0.6. For FlowBrandLockup at variable height: -(height × 1.35 × 0.02).
Font parity rule: FlowBrandLockup’s wordmark also uses GoogleFonts.inter() (not bare TextStyle()). Both must use the same GoogleFonts call so they render identically regardless of DefaultTextStyle context. Never use bare TextStyle(fontFamily: ...) for brand or title text — always go through GoogleFonts.inter().
Canonical source:
flow_docs/content/project/brand-book.md. The lockup SVGs in that file define the authoritative weight (900) and letter-spacing (-0.02em). If the brand spec ever changes, update bothpageTitleStyleandFlowBrandLockuptogether.
Never use
const Text(style: AppTheme.pageTitleStyle)—pageTitleStyleis afinal(runtime) value, not a compile-time constant. TheconstonTextwill cause aninvalid_constantanalysis error.
Brand Logo Rule
FlowBrandLockup (mark + “flow” wordmark) appears only on the Feed (Home) screen.
All other navbar tab screens use FlowBrandMark(height: 18) + Text(..., style: AppTheme.pageTitleStyle) side-by-side — the mark echoes the logo identity while the text changes per screen. Pushed (secondary) screens use plain Text only.
// Navbar tab screens — mark + title
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FlowBrandMark(height: 18),
const SizedBox(width: 10),
Text(l10n.events, style: AppTheme.pageTitleStyle),
],
)| Screen | Header widget | Leading |
|---|---|---|
| Feed / Home | FlowBrandLockup(height: 22) | none (root tab) |
| Events | FlowBrandMark(18) + Text(l10n.events) | none (root tab) |
| Social / Friends | FlowBrandMark(18) + Text(l10n.socialLabel) | none (root tab) |
| Chats | FlowBrandMark(18) + Text('Messaggi') | none (embedded) |
| Profile | FlowBrandMark(18) + Text(l10n.profile) | none (root tab) |
| Settings | Text(l10n.settings, style: AppTheme.pageTitleStyle) | AppIcons.back |
| All settings sub-screens | Text(l10n.x, style: AppTheme.pageTitleStyle) | AppIcons.back |
| Notifications | Text(l10n.notificationsTitle, style: AppTheme.pageTitleStyle) | AppIcons.back (custom header) |
| Leaderboard | Text(l10n.leaderboard, style: AppTheme.pageTitleStyle) | AppIcons.back |
| Edit Profile | Text(l10n.editProfile, style: AppTheme.pageTitleStyle) | AppIcons.close (dismiss) |
Back Button Rule
Every pushed route must have a dedicated back/close button in the leading position:
- Use
AppIcons.back(chevron-left) for standard back navigation - Use
AppIcons.close(×) for modal/form dismissal (edit screens, creation screens) - Use
context.pop()(GoRouter) for theonPressedhandler - Screens with a custom header (no AppBar) must add the back
IconButtonmanually — Flutter cannot auto-add leading to non-AppBar layouts
Top-Bar Icon Button Standard
All action icons in page headers (AppBar actions or custom header Rows) MUST use IconButton directly — no Container wrappers, no custom BoxDecoration, no GestureDetector around a styled box.
// ✅ Correct — transparent bg, consistent touch target, accessible
IconButton(
icon: const Icon(AppIcons.share, size: 22),
onPressed: _shareProfile,
color: AppTheme.inkBlack, // explicit on non-AppBar headers
visualDensity: VisualDensity.compact, // tighter layout when needed
)
// ❌ Wrong — warmCanvas Container was the old pattern, now removed
Container(
width: 38, height: 38,
decoration: BoxDecoration(color: AppTheme.warmCanvas, ...),
child: Icon(icon, size: 20, ...),
)Dark screens (e.g. TicketWalletScreen): use color: Colors.white on the IconButton.
Brand-colored actions (e.g. new-chat button): use color: AppTheme.electricCoral — the accent is on the icon, not a background fill.
Notification bell badge: keep the Stack wrapper for the badge overlay; replace only the inner Container+Icon with IconButton.
Nav-Tab FAB Rule
Navbar-tab screens (reachable from the bottom nav) must NOT use FloatingActionButton — the FAB is visually obscured by the bottom navbar. Primary actions go in SliverAppBar.actions as IconButtons instead:
| Screen | Action moved | Previous location |
|---|---|---|
| Events | AppIcons.add → create event | FloatingActionButton |
Never add
FlowBrandLockupto non-feed screens. The brand mark is a unique identifier for the discovery feed, not a generic title widget.
Icon Packages
Three icon packages are active. Do not collapse them — each serves a different purpose.
| Package | Import | Used for |
|---|---|---|
lucide_icons | LucideIcons.* | All navigation / top-bar chrome via AppIcons constants |
font_awesome_flutter | FontAwesomeIcons.apple, .google | Full-width social buttons on welcome screen |
material_design_icons_flutter | MdiIcons.google, .apple, .facebook | 3-icon divider row on login / forgot screens |
AppIcons — The Nav Icon Contract
lib/core/theme/app_icons.dart is the single point of truth for all navigation and top-bar icons. Always use AppIcons.* constants — never import LucideIcons.* directly in screen files.
// Bottom nav
AppIcons.navHome // LucideIcons.home
AppIcons.navSearch // LucideIcons.search
AppIcons.navCreate // LucideIcons.plus
AppIcons.navSocial // LucideIcons.zap
AppIcons.navProfile // LucideIcons.user
// Top bar
AppIcons.back // LucideIcons.chevronLeft ← replaces all arrow_back variants
AppIcons.close // LucideIcons.x
AppIcons.settings // LucideIcons.settings
AppIcons.notifications // LucideIcons.bell
AppIcons.share // LucideIcons.share2
AppIcons.more // LucideIcons.moreVerticalLucide active-state rule: Lucide icons are line-only (no filled variant). Active bottom-nav tabs use electricCoral color + a 4px dot indicator below the icon — not a filled icon switch.
MDI and FA icons are rendered monochrome at AppTheme.textSecondary — never brand-colored.
Icon Migration Status — Completed 2026-05-06
All 9 queued screens have been migrated to AppIcons.* for nav chrome:
events/create_event_screen.dart✓events/event_details_screen.dart✓events/series_detail_screen.dart✓social/create_crew_screen.dart✓social/crew_matchmaking_wizard.dart✓ (3 inline nav buttons;import app_icons.dartadded)messaging/chat_screen.dart✓messaging/chats_screen.dart✓reviews/create_review_screen.dart✓profile/edit_profile_screen.dart✓
Body-content icons (chip delete, list-row chevron_right, star ratings, Icons.groups) are intentionally excluded from the Lucide migration — they are content icons, not nav chrome. The chats_screen.dart search-bar prefix/clear and chat_screen.dart reply-cancel were migrated on 2026-05-06 as they are inline UI chrome, not semantic content indicators.
AppBar Consistency — Completed 2026-05-06
The following screens were missing scrolledUnderElevation: 0 and/or using scaffoldBackgroundColor instead of AppTheme.pureWhite. Fixed:
chats_screen.dart— addedscrolledUnderElevation: 0edit_profile_screen.dart— addedscrolledUnderElevation: 0;backgroundColor→AppTheme.pureWhitecreate_crew_screen.dart— addedscrolledUnderElevation: 0;backgroundColor→AppTheme.pureWhite;surfaceTintColor→Colors.transparentcreate_event_screen.dart— addedscrolledUnderElevation: 0to both AppBars (preset picker + wizard)settings_screen.dart,about_screen.dart,notifications_settings_screen.dart,privacy_settings_screen.dart—scaffoldBackgroundColor→AppTheme.pureWhite;scrolledUnderElevation: 0;surfaceTintColor: Colors.transparentleaderboard_screen.dart,create_review_screen.dart,series_detail_screen.dart— same tokens appliedevent_details_screen.dart— error/not-found AppBars:pageTitleStyle,AppIcons.backleading,pureWhitetokens
Deferred Technical Debt
- Shadow dark-mode:
shadowSM/MD/LGuseColors.black, invisible oninkBlackbackgrounds. Requires brightness-aware shadows or Material 3surfaceTint. Low priority. - 147 legacy alias call-sites: Mechanical rename to canonical tokens — separate commit, low regression risk but high churn.
- Violet-tinted shadows: Evaluate after visual consistency is achieved.
Related
auth-screens — auth-specific token usage style-ui-redesign-pass-1 — first redesign pass context