// Theme assembly. ColorScheme + typography + component themes. import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; class FaiTheme { FaiTheme._(); /// Inter for UI, JetBrains Mono for technical strings (IDs, /// paths, capability refs). Both via google_fonts so they /// bundle on first run; for production we'd ship them as /// app assets. static TextTheme _textTheme(Brightness b) { final base = b == Brightness.dark ? Typography.whiteCupertino : Typography.blackCupertino; return GoogleFonts.interTextTheme(base).copyWith( displaySmall: GoogleFonts.inter( fontSize: 28, fontWeight: FontWeight.w600, letterSpacing: -0.4, ), headlineSmall: GoogleFonts.inter( fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: -0.2, ), titleMedium: GoogleFonts.inter( fontSize: 15, fontWeight: FontWeight.w600, letterSpacing: 0, ), titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500), bodyLarge: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w400, height: 1.45, ), bodyMedium: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w400, height: 1.4, ), bodySmall: GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w400, height: 1.4, ), labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500), labelSmall: GoogleFonts.inter( fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 0.2, ), ); } /// JetBrains Mono for monospaced technical content. Used by /// FaiMono helper. static TextStyle mono({ double size = 12, FontWeight weight = FontWeight.w400, Color? color, }) => GoogleFonts.jetBrainsMono( fontSize: size, fontWeight: weight, color: color, height: 1.4, ); /// Shared markdown style used by every inline doc renderer /// (Welcome DocReaderSheet, Store module-detail DocsPanel, /// future help surfaces). Centralising it means every doc /// reads in the same typography, with the same dark-theme /// safe colors. /// /// Why every property is set explicitly even when it just /// re-states the default: /// /// - `flutter_markdown`'s `MarkdownStyleSheet.fromTheme(theme)` /// sets `code` with `backgroundColor: theme.cardTheme.color`. /// That per-span backgroundColor is rendered behind every /// character inside a fenced code block, which on a dark /// theme shows up as horizontal bands behind ASCII diagrams. /// We force `backgroundColor: Colors.transparent` to suppress /// it — the codeblock's own decoration provides the panel /// background. /// - `blockquote` defaults to a hardcoded `Colors.blue.shade100` /// background that is unreadable in dark mode. We replace it /// with theme-aware tokens (surfaceContainer + left accent /// border). /// - `tableCellsDecoration` has the same dark-mode bug, fixed /// the same way. static MarkdownStyleSheet markdownStyle(ThemeData theme) { return MarkdownStyleSheet.fromTheme(theme).copyWith( p: theme.textTheme.bodyMedium?.copyWith(height: 1.5), code: mono( size: 12, color: theme.colorScheme.onSurface, ).copyWith(backgroundColor: Colors.transparent), codeblockDecoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), codeblockPadding: const EdgeInsets.all(FaiSpace.md), blockquote: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), blockquoteDecoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border( left: BorderSide(color: theme.colorScheme.primary, width: 3), ), ), blockquotePadding: const EdgeInsets.fromLTRB( FaiSpace.md, FaiSpace.sm, FaiSpace.sm, FaiSpace.sm, ), tableCellsDecoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, ), ); } /// Build a Studio ThemeData around any [ColorScheme]. Used by /// the built-in light/dark constructors and by /// `themeDataFromScheme` when a Studio plugin supplies the /// palette. Single entry-point so the plugin path and the /// built-in path always produce structurally identical /// ThemeData (same textTheme, same component themes); without /// this, swapping between them at runtime trips Flutter's /// TextStyle.lerp with mismatched `inherit` values. static ThemeData fromColorScheme(ColorScheme scheme) => _build(scheme); static ThemeData dark() { final scheme = ColorScheme( brightness: Brightness.dark, primary: FaiColors.accent, onPrimary: FaiColors.black, primaryContainer: FaiColors.accentMuted, onPrimaryContainer: FaiColors.textStrong, secondary: FaiColors.accentDeep, onSecondary: FaiColors.black, secondaryContainer: FaiColors.surfaceHigh, onSecondaryContainer: FaiColors.text, tertiary: FaiColors.accent, onTertiary: FaiColors.black, tertiaryContainer: FaiColors.surfaceHigh, onTertiaryContainer: FaiColors.text, error: FaiColors.danger, onError: FaiColors.textStrong, errorContainer: const Color(0xFF3F1518), onErrorContainer: const Color(0xFFFCA5A5), surface: FaiColors.black, onSurface: FaiColors.text, onSurfaceVariant: FaiColors.muted, outline: FaiColors.border, outlineVariant: FaiColors.border, surfaceContainerLowest: FaiColors.black, surfaceContainerLow: FaiColors.surface, surfaceContainer: FaiColors.surface, surfaceContainerHigh: FaiColors.surfaceHigh, surfaceContainerHighest: FaiColors.surfaceHigh, ); return _build(scheme); } static ThemeData light() { final scheme = ColorScheme( brightness: Brightness.light, primary: FaiColors.accentDeep, onPrimary: FaiColors.lightSurface, primaryContainer: const Color(0xFFE0F2FE), onPrimaryContainer: FaiColors.accentMuted, secondary: FaiColors.accentMuted, onSecondary: FaiColors.lightSurface, secondaryContainer: FaiColors.lightSurfaceHigh, onSecondaryContainer: FaiColors.lightText, tertiary: FaiColors.accentDeep, onTertiary: FaiColors.lightSurface, tertiaryContainer: const Color(0xFFE0F2FE), onTertiaryContainer: FaiColors.accentMuted, error: FaiColors.danger, onError: FaiColors.lightSurface, errorContainer: const Color(0xFFFEE2E2), onErrorContainer: const Color(0xFF7F1D1D), surface: FaiColors.lightCanvas, onSurface: FaiColors.lightText, onSurfaceVariant: FaiColors.lightMuted, outline: FaiColors.lightBorder, outlineVariant: FaiColors.lightBorder, surfaceContainerLowest: FaiColors.lightCanvas, surfaceContainerLow: FaiColors.lightSurface, surfaceContainer: FaiColors.lightSurface, surfaceContainerHigh: FaiColors.lightSurfaceHigh, surfaceContainerHighest: FaiColors.lightSurfaceHigh, ); return _build(scheme); } static ThemeData _build(ColorScheme scheme) { final textTheme = _textTheme(scheme.brightness); return ThemeData( useMaterial3: true, colorScheme: scheme, scaffoldBackgroundColor: scheme.surface, textTheme: textTheme, primaryTextTheme: textTheme, appBarTheme: AppBarTheme( backgroundColor: scheme.surface, foregroundColor: scheme.onSurface, iconTheme: IconThemeData(color: scheme.onSurface), elevation: 0, scrolledUnderElevation: 0, centerTitle: false, // Bake the foreground colour straight into the title // style. `headlineSmall` is built from GoogleFonts.inter // with no color slot, and Material's "merge foreground // colour at draw time" path leaves it null in some // light-mode builds — the title rendered white-on-white. titleTextStyle: textTheme.headlineSmall?.copyWith( color: scheme.onSurface, ), toolbarHeight: 64, ), cardTheme: CardThemeData( color: scheme.surfaceContainer, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.md), side: BorderSide(color: scheme.outlineVariant), ), margin: EdgeInsets.zero, ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.sm), ), padding: const EdgeInsets.symmetric( horizontal: FaiSpace.lg, vertical: FaiSpace.md, ), textStyle: textTheme.labelMedium, animationDuration: FaiMotion.fast, ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( foregroundColor: scheme.onSurface, side: BorderSide(color: scheme.outline), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.sm), ), padding: const EdgeInsets.symmetric( horizontal: FaiSpace.lg, vertical: FaiSpace.md, ), textStyle: textTheme.labelMedium, animationDuration: FaiMotion.fast, ), ), iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom(foregroundColor: scheme.onSurfaceVariant), ), navigationRailTheme: NavigationRailThemeData( backgroundColor: scheme.surfaceContainerLow, indicatorColor: scheme.primaryContainer, selectedIconTheme: IconThemeData(color: scheme.primary, size: 22), unselectedIconTheme: IconThemeData( color: scheme.onSurfaceVariant, size: 22, ), selectedLabelTextStyle: textTheme.labelMedium?.copyWith( color: scheme.primary, ), unselectedLabelTextStyle: textTheme.labelMedium?.copyWith( color: scheme.onSurfaceVariant, ), useIndicator: true, ), dividerColor: scheme.outlineVariant, dividerTheme: DividerThemeData( color: scheme.outlineVariant, thickness: 1, space: 1, ), snackBarTheme: SnackBarThemeData( backgroundColor: scheme.surfaceContainerHigh, contentTextStyle: textTheme.bodyMedium?.copyWith( color: scheme.onSurface, ), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.sm), ), ), ); } }