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:
2026-07-17 23:46:26 +02:00
parent 202e9d8db3
commit 2686c175f3
10 changed files with 535 additions and 81 deletions

436
lib/presentation/theme.dart Normal file
View 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;
}
}