import 'package:flutter/material.dart'; import 'gapped_track_shape.dart'; import 'mono_tokens.dart'; ThemeData monoTheme({required bool dark, bool oled = false}) { // neutral greys tuned for crisp contrast final ({Color bg, Color surface, Color outline, Color text, Color textMuted}) c; if (oled) { c = ( bg: const Color(0xFF000000), // Pure black for OLED surface: const Color(0xFF0A0A0A), // Very dark gray outline: const Color(0x1FFFFFFF), text: const Color(0xFFEDEDED), textMuted: const Color(0x99EDEDED), ); } else if (dark) { c = ( bg: const Color(0xFF0E0F12), surface: const Color(0xFF15171C), outline: const Color(0x1FFFFFFF), text: const Color(0xFFEDEDED), textMuted: const Color(0x99EDEDED), ); } else { c = ( bg: const Color(0xFFF7F7F8), surface: const Color(0xFFFFFFFF), outline: const Color(0x19000000), text: const Color(0xFF111111), textMuted: const Color(0x99111111), ); } final isDark = dark || oled; final clickableCursor = WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.disabled) ? MouseCursor.defer : SystemMouseCursors.click, ); final buttonStyle = ButtonStyle( mouseCursor: clickableCursor, padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 18, vertical: 14)), elevation: const WidgetStatePropertyAll(0), backgroundColor: WidgetStatePropertyAll(c.text), foregroundColor: WidgetStatePropertyAll(isDark ? c.bg : Colors.white), shape: const WidgetStatePropertyAll(StadiumBorder()), ); final base = ThemeData( useMaterial3: true, brightness: isDark ? Brightness.dark : Brightness.light, colorScheme: ColorScheme( brightness: isDark ? Brightness.dark : Brightness.light, primary: c.text, onPrimary: isDark ? c.bg : Colors.white, secondary: c.text, onSecondary: c.bg, surface: c.surface, onSurface: c.text, error: const Color(0xFFB00020), onError: Colors.white, tertiary: c.text, onTertiary: c.bg, primaryContainer: c.surface, onPrimaryContainer: c.text, secondaryContainer: c.surface, onSecondaryContainer: c.text, surfaceContainerHighest: c.surface, surfaceContainerLow: c.bg, surfaceDim: c.bg, surfaceBright: c.surface, outline: c.outline, shadow: Colors.transparent, scrim: Colors.black, inverseSurface: c.text, onInverseSurface: c.bg, inversePrimary: c.bg, ), // remove "Material feel" splashFactory: NoSplash.splashFactory, highlightColor: Colors.transparent, // Explicit mono-derived tile highlights: ListTile's native focus/hover // fill is the dpad focus visual inside M3E grouped-list cards. focusColor: c.text.withValues(alpha: 0.12), hoverColor: c.text.withValues(alpha: 0.05), dividerColor: c.outline, scaffoldBackgroundColor: c.bg, appBarTheme: AppBarTheme( backgroundColor: c.bg, elevation: 0, scrolledUnderElevation: 0, centerTitle: false, foregroundColor: c.text, titleTextStyle: TextStyle(color: c.text, fontSize: 18, fontWeight: .w700, letterSpacing: -0.2), ), textTheme: Typography.englishLike2021 .apply(bodyColor: c.text, displayColor: c.text) .copyWith( displayLarge: const TextStyle(fontWeight: .w700, letterSpacing: -0.5), titleMedium: const TextStyle(fontWeight: .w600), bodyMedium: TextStyle(color: c.text), bodySmall: TextStyle(color: c.textMuted), ), cardTheme: CardThemeData( color: c.surface, elevation: 0, margin: .zero, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14))), ), inputDecorationTheme: _inputDecorationTheme(c.text, c.textMuted), elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle), filledButtonTheme: FilledButtonThemeData(style: buttonStyle), textButtonTheme: TextButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)), outlinedButtonTheme: OutlinedButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)), iconButtonTheme: IconButtonThemeData(style: ButtonStyle(mouseCursor: clickableCursor)), sliderTheme: SliderThemeData( // The mono scheme maps surfaceContainerHighest (the M3 default inactive // track) to the same color as surface cards, which makes the inactive // track invisible inside grouped-list items. inactiveTrackColor: c.text.withValues(alpha: 0.12), trackHeight: 16, trackGap: 6, thumbSize: const WidgetStatePropertyAll(Size(4, 20)), thumbShape: const HandleThumbShape(), trackShape: const GappedTrackShape(), tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 2), // ignore: deprecated_member_use — opting into the 2024 slider appearance until the default flips year2023: false, ), dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline), listTileTheme: ListTileThemeData( dense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), iconColor: c.text, textColor: c.text, ), navigationBarTheme: NavigationBarThemeData( backgroundColor: c.bg, elevation: 0, indicatorColor: Colors.transparent, labelTextStyle: WidgetStatePropertyAll(TextStyle(color: c.textMuted, fontSize: 11)), iconTheme: WidgetStateProperty.resolveWith((states) { final active = states.contains(WidgetState.selected); return IconThemeData(opacity: active ? 1 : 0.6, size: 22, color: c.text); }), ), // Floating snackbars auto-offset above the Scaffold's bottom NavigationBar, // so they don't cover it on mobile. Background color tracks the theme to // avoid jarring brightness on HDR playback / dark mode. snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, backgroundColor: c.surface, contentTextStyle: TextStyle(color: c.text), actionTextColor: c.text, elevation: 6, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), insetPadding: const EdgeInsets.all(16), ), ); return base.copyWith( extensions: [ MonoTokens( radiusSm: 8, radiusMd: 12, radiusLg: 20, radiusXs: 5, groupGap: 2, space: 12, fast: const Duration(milliseconds: 120), normal: const Duration(milliseconds: 200), slow: const Duration(milliseconds: 300), expressive: const Duration(milliseconds: 350), bg: c.bg, surface: c.surface, outline: c.outline, text: c.text, textMuted: c.textMuted, splashFactory: NoSplash.splashFactory, ), ], ); } /// Brighter fill on focus so input focus is visible inside TV overscan. InputDecorationTheme _inputDecorationTheme(Color text, Color textMuted) { final unfocusedFill = text.withValues(alpha: 0.08); final focusedFill = text.withValues(alpha: 0.18); const border = OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12)), borderSide: BorderSide.none); return InputDecorationTheme( filled: true, fillColor: WidgetStateColor.resolveWith( (states) => states.contains(WidgetState.focused) ? focusedFill : unfocusedFill, ), isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), border: border, enabledBorder: border, focusedBorder: border, hintStyle: TextStyle(color: textMuted), ); }