import 'package:flutter/material.dart'; const _radius = 6.0; const _badgeRadius = 5.0; @immutable final class CourtBlazerTokens extends ThemeExtension { const CourtBlazerTokens({ required this.accent, required this.border, required this.mutedText, required this.success, }); final Color accent; final Color border; final Color mutedText; final Color success; @override CourtBlazerTokens copyWith({ Color? accent, Color? border, Color? mutedText, Color? success, }) { return CourtBlazerTokens( accent: accent ?? this.accent, border: border ?? this.border, mutedText: mutedText ?? this.mutedText, success: success ?? this.success, ); } @override CourtBlazerTokens lerp(ThemeExtension? other, double t) { if (other is! CourtBlazerTokens) return this; return CourtBlazerTokens( accent: Color.lerp(accent, other.accent, t)!, border: Color.lerp(border, other.border, t)!, mutedText: Color.lerp(mutedText, other.mutedText, t)!, success: Color.lerp(success, other.success, t)!, ); } } final class AppTextStyles { const AppTextStyles._(); static TextStyle scoreNumber(BuildContext context) { return Theme.of(context).textTheme.displaySmall!.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ); } static TextStyle timer(BuildContext context) { return Theme.of(context).textTheme.displayLarge!.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ); } } CourtBlazerTokens courtBlazerTokensOf(BuildContext context) { final theme = Theme.of(context); return theme.extension() ?? CourtBlazerTokens( accent: theme.colorScheme.secondary, border: theme.dividerColor, mutedText: theme.colorScheme.onSurfaceVariant, success: Colors.green, ); } ThemeData courtBlazerLightTheme() { return _courtBlazerTheme( brightness: Brightness.light, primary: const Color(0xFF9E7623), accent: const Color(0xFFB91C2B), background: const Color(0xFFF4F1EA), surface: Colors.white, text: const Color(0xFF11131A), mutedText: const Color(0xFF626A78), border: const Color(0xFFE4DFD2), success: const Color(0xFF178A4A), error: const Color(0xFFC81E32), ); } ThemeData courtBlazerDarkTheme() { return _courtBlazerTheme( brightness: Brightness.dark, primary: const Color(0xFFC9A24A), accent: const Color(0xFFD72638), background: const Color(0xFF080A12), surface: const Color(0xFF141824), text: const Color(0xFFF5F1E8), mutedText: const Color(0xFFA7ADBA), border: const Color(0xFF242A3A), success: const Color(0xFF2ECC71), error: const Color(0xFFFF4D5E), ); } ThemeData _courtBlazerTheme({ required Brightness brightness, required Color primary, required Color accent, required Color background, required Color surface, required Color text, required Color mutedText, required Color border, required Color success, required Color error, }) { final colorScheme = ColorScheme( brightness: brightness, primary: primary, onPrimary: brightness == Brightness.dark ? background : surface, secondary: accent, onSecondary: surface, error: error, onError: surface, surface: surface, onSurface: text, ); final baseTextTheme = _textTheme(text, mutedText); final roundedBorder = RoundedRectangleBorder( borderRadius: BorderRadius.circular(_radius), side: BorderSide(color: border), ); return ThemeData( useMaterial3: true, brightness: brightness, colorScheme: colorScheme, scaffoldBackgroundColor: background, fontFamily: 'Archivo', textTheme: baseTextTheme, appBarTheme: AppBarTheme( centerTitle: false, elevation: 0, scrolledUnderElevation: 0, backgroundColor: background, foregroundColor: text, titleTextStyle: baseTextTheme.titleLarge, ), cardTheme: CardThemeData( elevation: 0, color: surface, surfaceTintColor: Colors.transparent, shape: roundedBorder, margin: EdgeInsets.zero, ), chipTheme: ChipThemeData( backgroundColor: surface, selectedColor: primary.withAlpha(41), disabledColor: border, labelStyle: baseTextTheme.labelMedium, secondaryLabelStyle: baseTextTheme.labelMedium, side: BorderSide(color: border), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_badgeRadius), ), ), dividerTheme: DividerThemeData(color: border, thickness: 1), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: surface, border: _inputBorder(border), enabledBorder: _inputBorder(border), focusedBorder: _inputBorder(primary, width: 1.5), errorBorder: _inputBorder(error), ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_radius), ), textStyle: baseTextTheme.labelLarge, ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( side: BorderSide(color: border), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_radius), ), textStyle: baseTextTheme.labelLarge, ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom(textStyle: baseTextTheme.labelLarge), ), floatingActionButtonTheme: FloatingActionButtonThemeData( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_radius), ), ), listTileTheme: ListTileThemeData( iconColor: mutedText, titleTextStyle: baseTextTheme.titleSmall, subtitleTextStyle: baseTextTheme.bodyMedium?.copyWith(color: mutedText), ), bannerTheme: MaterialBannerThemeData( backgroundColor: surface, dividerColor: Colors.transparent, contentTextStyle: baseTextTheme.bodyMedium, ), extensions: [ CourtBlazerTokens( accent: accent, border: border, mutedText: mutedText, success: success, ), ], ); } TextTheme _textTheme(Color text, Color mutedText) { const anton = 'Anton'; const archivo = 'Archivo'; return TextTheme( displayLarge: TextStyle( fontFamily: anton, fontSize: 56, height: 1, color: text, ), displayMedium: TextStyle( fontFamily: anton, fontSize: 44, height: 1.05, color: text, ), displaySmall: TextStyle( fontFamily: anton, fontSize: 34, height: 1.05, color: text, ), headlineLarge: TextStyle( fontFamily: anton, fontSize: 32, height: 1.1, color: text, ), headlineMedium: TextStyle( fontFamily: anton, fontSize: 28, height: 1.1, color: text, ), headlineSmall: TextStyle( fontFamily: anton, fontSize: 24, height: 1.15, color: text, ), titleLarge: TextStyle( fontFamily: anton, fontSize: 22, height: 1.15, color: text, ), titleMedium: TextStyle( fontFamily: anton, fontSize: 18, height: 1.2, color: text, ), titleSmall: TextStyle( fontFamily: archivo, fontSize: 16, fontWeight: FontWeight.w700, color: text, ), bodyLarge: TextStyle(fontFamily: archivo, fontSize: 16, color: text), bodyMedium: TextStyle(fontFamily: archivo, fontSize: 14, color: text), bodySmall: TextStyle(fontFamily: archivo, fontSize: 12, color: mutedText), labelLarge: TextStyle( fontFamily: archivo, fontSize: 14, fontWeight: FontWeight.w700, color: text, ), labelMedium: TextStyle( fontFamily: archivo, fontSize: 12, fontWeight: FontWeight.w700, color: text, ), ); } OutlineInputBorder _inputBorder(Color color, {double width = 1}) { return OutlineInputBorder( borderRadius: BorderRadius.circular(_radius), borderSide: BorderSide(color: color, width: width), ); } final class CourtBlazerAccentPanel extends StatelessWidget { const CourtBlazerAccentPanel({ required this.child, this.padding, this.margin, super.key, }); final Widget child; final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? margin; @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = courtBlazerTokensOf(context); return Container( margin: margin, decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(_radius), border: Border.all(color: tokens.border), ), clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Container(height: 2, color: tokens.accent), Padding(padding: padding ?? EdgeInsets.zero, child: child), ], ), ); } } final class GameTimeLogo extends StatelessWidget { const GameTimeLogo({this.wordmark = true, this.height = 36, super.key}); final bool wordmark; final double height; @override Widget build(BuildContext context) { final textStyle = Theme.of(context).textTheme.titleLarge!.copyWith( fontFamily: 'Anton', fontSize: height * 0.54, ); return Row( mainAxisSize: MainAxisSize.min, children: [ CustomPaint( size: Size(height * 1.22, height), painter: _GameTimePatchPainter(Theme.of(context)), ), if (wordmark) ...[ const SizedBox(width: 10), Text('GameTime', style: textStyle), ], ], ); } } final class _GameTimePatchPainter extends CustomPainter { const _GameTimePatchPainter(this.theme); final ThemeData theme; @override void paint(Canvas canvas, Size size) { final tokens = theme.extension() ?? CourtBlazerTokens( accent: theme.colorScheme.secondary, border: theme.dividerColor, mutedText: theme.colorScheme.onSurfaceVariant, success: Colors.green, ); final rect = Offset.zero & size; final radius = Radius.circular(size.height * 0.25); final shape = RRect.fromRectAndRadius(rect.deflate(1), radius); final background = Paint()..color = theme.colorScheme.surface; final border = Paint() ..color = theme.colorScheme.primary ..style = PaintingStyle.stroke ..strokeWidth = 2; canvas.drawRRect(shape, background); canvas.save(); canvas.clipRRect(shape); final stripe = Path() ..moveTo(size.width * 0.08, size.height) ..lineTo(size.width * 0.34, size.height) ..lineTo(size.width * 0.92, 0) ..lineTo(size.width * 0.66, 0) ..close(); canvas.drawPath(stripe, Paint()..color = tokens.accent); canvas.restore(); canvas.drawRRect(shape, border); canvas.drawCircle( Offset(size.width * 0.22, size.height * 0.25), size.height * 0.055, Paint()..color = theme.colorScheme.primary, ); final textPainter = TextPainter( text: TextSpan( text: 'GT', style: TextStyle( fontFamily: 'Anton', fontSize: size.height * 0.52, color: theme.colorScheme.onSurface, height: 1, ), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset( (size.width - textPainter.width) / 2, (size.height - textPainter.height) / 2 + size.height * 0.03, ), ); } @override bool shouldRepaint(covariant _GameTimePatchPainter oldDelegate) { return oldDelegate.theme != theme; } }