feat(ui): identité visuelle Court Blazer sur toute l'app (ticket #17)
Ajoute le thème centralisé (presentation/theme.dart) basé sur les polices Anton/Archivo et l'applique à tous les écrans (accueil, bibliothèque d'exercices, programme, séance-modèle, exécution de séance, historique). flutter analyze propre, 27/27 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
436
lib/presentation/theme.dart
Normal file
436
lib/presentation/theme.dart
Normal file
@ -0,0 +1,436 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _radius = 6.0;
|
||||
const _badgeRadius = 5.0;
|
||||
|
||||
@immutable
|
||||
final class CourtBlazerTokens extends ThemeExtension<CourtBlazerTokens> {
|
||||
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<CourtBlazerTokens>? 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>() ??
|
||||
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>() ??
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user