merge(main): identité visuelle Court Blazer sur toute l'app (ticket #17)

Fusionne feature/#17-identite-visuelle — 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:34 +02:00
20 changed files with 675 additions and 82 deletions

View File

@ -4,3 +4,4 @@
- [gametime-ux-conception](gametime-ux-conception.md) — memory note gametime-ux-conception
- [gametime-architecture-initial-stack-data-model](gametime-architecture-initial-stack-data-model.md) — memory note gametime-architecture-initial-stack-data-model
- [gametime-dev-environment](gametime-dev-environment.md) — memory note gametime-dev-environment
- [gametime-visual-identity](gametime-visual-identity.md) — memory note gametime-visual-identity

View File

@ -0,0 +1,62 @@
---
name: gametime-visual-identity
description: memory note gametime-visual-identity
metadata:
type: project
---
# GameTime — Identité visuelle retenue : "Court Blazer"
Décision finale validée par l'utilisateur (2026-07-17), après comparaison de 4 pistes dans un artifact de maquette (Grain de Jeu, Cour d'École, Court Elite, Court Blazer).
**Court Blazer = couleurs de Court Elite + structure/logo de Cour d'École + typographie de Cour d'École** (pas Fraunces, qui avait été proposé puis écarté : l'utilisateur a préféré rester sur Anton/Archivo, jugées suffisamment affirmées).
## Palette
Thème sombre :
| Rôle | Hex |
|---|---|
| Primaire (or) | `#C9A24A` |
| Accent (crimson) | `#D72638` |
| Fond | `#080A12` |
| Surface / carte | `#141824` |
| Texte principal | `#F5F1E8` |
| Texte secondaire | `#A7ADBA` |
| Bordure | `#242A3A` |
Thème clair :
| Rôle | Hex |
|---|---|
| Primaire (or bruni) | `#9E7623` |
| Accent (crimson) | `#B91C2B` |
| Fond | `#F4F1EA` |
| Surface / carte | `#FFFFFF` |
| Texte principal | `#11131A` |
| Texte secondaire | `#626A78` |
| Bordure | `#E4DFD2` |
Succès `#2ECC71` (sombre) / `#178A4A` (clair), Erreur `#FF4D5E` (sombre) / `#C81E32` (clair) — repris de la piste Court Elite d'origine.
## Typographie
- **Anton** (Regular, poids unique) pour tous les titres, l'app-wordmark, le nom d'exercice, et surtout les **chiffres** (chrono d'exécution, valeurs de séries/répétitions/score) — grand format façon tableau de score.
- **Archivo** (police variable, weight 400 pour le corps, 600-700 pour emphase) pour tout le texte courant, labels, descriptions.
- Fichiers de police bundlés en local (offline-first, pas de dépendance réseau type `google_fonts` package) : `assets/fonts/Anton-Regular.ttf` et `assets/fonts/Archivo-Variable.ttf` (police variable, Flutter gère nativement la sélection de graisse via `FontWeight` sur ce fichier unique).
## Forme et composants
- Rayon de bordure : 6px (entre le carré strict de Cour d'École et l'arrondi de Court Elite).
- Cartes et panneaux : plats, sans ombre portée (pas de glassmorphism, pas d'ombre "tamponnée").
- Liseré supérieur de 2px en couleur accent (crimson) sur les cartes clés (bloc chrono, carte historique, bandeau de reprise) — clin d'œil "tableau de score", en version atténuée par rapport à Cour d'École (qui utilisait 3px).
- Badges : rayon 5px (ni pilule complète, ni carré strict).
## Logo
Concept "patch" hérité de Cour d'École, recoloré :
- Rectangle à coins arrondis (rx 9-10), fond `surface`, contour `primary` (or).
- Bande diagonale traversant le badge (via clipPath), couleur `accent` (crimson).
- Petit point plein `primary` en haut à gauche (accent ballon).
- Monogramme "GT" centré, police Anton (au lieu de Fraunces envisagé un temps).
## Référence
Maquette comparative complète (4 pistes, clair/sombre) conservée dans l'artifact Claude : voir historique de conversation Main du 2026-07-17. Cette mémoire fait foi pour l'implémentation Flutter réelle (tickets d'implémentation DA, cf. tickets IdeA #16+).

View File

@ -0,0 +1,16 @@
---
issueRef: "#17"
version: 4
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784324772157
---
Détail technique à respecter :
- pubspec.yaml : déclarer les polices locales sous `flutter: fonts:` avec deux familles : "Anton" (assets/fonts/Anton-Regular.ttf) et "Archivo" (assets/fonts/Archivo-Variable.ttf, police variable — Flutter gère nativement FontWeight dessus, pas besoin de fichiers séparés par graisse).
- Créer/étendre un fichier de thème centralisé (ex: lib/presentation/theme.dart) avec deux ThemeData (light/dark) construits depuis les tokens de la mémoire "gametime-visual-identity". Éviter de dupliquer les couleurs en dur dans chaque écran : passer par ColorScheme/TextTheme centralisés, réutilisés par tous les écrans existants (#6 à #10).
- textTheme : displayLarge/headlineLarge/titleLarge etc. en Anton pour les titres ; bodyLarge/bodyMedium/labelLarge en Archivo. Les chiffres du chrono, du stepper de répétitions et du score doivent utiliser explicitement Anton (via un TextStyle dédié réutilisable, ex: `AppTextStyles.scoreNumber`), avec `fontFeatures`/`fontVariations` tabulaires si disponible pour éviter les sauts visuels.
- Le sélecteur de thème clair/sombre existant (ou à créer si absent) doit permuter entre les deux ThemeData sans redémarrer l'app.
- Composants : rayon de bordure global 6px (via `CardTheme`/`ButtonTheme`/etc. plutôt qu'en dur par écran), pas d'ombre portée sur les cartes (elevation 0 + bordure fine `--border`), liseré supérieur 2px couleur accent sur les cartes clés identifiées dans la mémoire (bloc chrono de l'écran d'exécution, carte d'historique, bandeau de reprise de séance).
- Logo : reproduire le concept "patch" (rectangle arrondi rx~9, contour or, bande diagonale crimson clippée, point or, monogramme "GT" en Anton) en widget Flutter réutilisable (CustomPainter ou assemblage de Container/ClipPath), utilisé dans l'app bar/en-tête à la place du texte seul actuel, et comme icône d'app Android (adaptive icon) si le temps le permet — sinon signale-le comme restant à faire.
- Vérifie que ce nouveau thème ne casse aucun test widget existant (les tests cherchent du texte/des types de widgets, pas des couleurs précises normalement, mais vérifie les assertions de type de bouton/style si certaines en dépendent).
Rappel environnement (mémoire "gametime-dev-environment") : ton sandbox n'a pas accès pub.dev, donne les commandes exactes à lancer côté Main (pub get, analyze, test, build apk).

View File

@ -0,0 +1,16 @@
---
id: "76558095-5b64-4a94-87f4-120eef982942"
number: 17
title: "[DevFrontend] Implémentation de l'identité visuelle \"Court Blazer\" sur toute l'app"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#9","kind":"relatesTo"},{"target":"#6","kind":"relatesTo"}]
agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784324260799
updatedAt: 1784324772157
version: 4
---
Appliquer la direction artistique retenue (mémoire "gametime-visual-identity") à l'ensemble de l'application Flutter GameTime : ThemeData clair et sombre (le thème doit rester sélectionnable par l'utilisateur, cf. mémoire "gametime-ux-conception"), typographie Anton (titres/chiffres) + Archivo (corps), palette Court Elite, forme de composants héritée de Cour d'École (rayon 6px, liseré haut 2px accent sur cartes clés, pas d'ombre portée), logo "patch" recoloré appliqué en tant qu'icône d'app et dans l'en-tête de l'application. Les polices sont déjà placées dans assets/fonts/ (Anton-Regular.ttf, Archivo-Variable.ttf).

View File

@ -0,0 +1,6 @@
---
issueRef: "#18"
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1784324398610
---

View File

@ -0,0 +1,16 @@
---
id: "bf1aef7d-5f3b-4354-aba9-851df92be313"
number: 18
title: "Ajouter un type de score spécial temps"
status: "open"
priority: "medium"
sprint: null
links: []
agentRefs: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784324324152
updatedAt: 1784324398610
version: 2
---
J'aimerais ajouter un type de score qui serait un temps avec un chrono intégré. Le but serait que l'utilisateur au début de sa série puisse lancer le chrono et l'arrêter à la fin. Ca lui permettrait ainsi d'avoir comme score le temps qu'il a mit à faire sa série sans avoir à lancer un autre chrono

View File

@ -1,3 +1,3 @@
{
"nextNumber": 16
"nextNumber": 19
}

View File

@ -180,6 +180,28 @@
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784319755572
},
{
"issueRef": "#17",
"path": "17",
"title": "[DevFrontend] Implémentation de l'identité visuelle \"Court Blazer\" sur toute l'app",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d"
],
"updatedAt": 1784324772157
},
{
"issueRef": "#18",
"path": "18",
"title": "Ajouter un type de score spécial temps",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"updatedAt": 1784324398610
}
]
}

Binary file not shown.

Binary file not shown.

View File

@ -97,7 +97,6 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
decoration: const InputDecoration(
labelText: 'Rechercher',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
@ -339,10 +338,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'Nom'),
textInputAction: TextInputAction.next,
validator: (value) => value == null || value.trim().isEmpty
? 'Le nom est obligatoire.'
@ -351,10 +347,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'Description'),
minLines: 2,
maxLines: 4,
),
@ -417,10 +410,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'Score à saisir'),
validator: (value) {
if (!_hasScore) return null;
return value == null || value.trim().isEmpty
@ -431,10 +421,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
const SizedBox(height: 12),
TextFormField(
controller: _scoreUnitController,
decoration: const InputDecoration(
labelText: 'Unité',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_hasScore) return null;
return value == null || value.trim().isEmpty
@ -684,10 +671,7 @@ final class _MediaImportField extends StatelessWidget {
: 'Aucun fichier sélectionné'
: fileName;
return InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
decoration: InputDecoration(labelText: label),
child: Row(
children: [
Icon(imported ? Icons.check_circle_outline : Icons.perm_media),

View File

@ -2,21 +2,32 @@ import 'package:flutter/material.dart';
import '../application/app_bootstrap.dart';
import 'home_screen.dart';
import 'theme.dart';
final class GameTimeApp extends StatelessWidget {
final class GameTimeApp extends StatefulWidget {
const GameTimeApp({required this.bootstrap, super.key});
final AppBootstrap bootstrap;
@override
State<GameTimeApp> createState() => _GameTimeAppState();
}
final class _GameTimeAppState extends State<GameTimeApp> {
var _themeMode = ThemeMode.system;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'GameTime',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0E7C66)),
useMaterial3: true,
theme: courtBlazerLightTheme(),
darkTheme: courtBlazerDarkTheme(),
themeMode: _themeMode,
home: HomeScreen(
bootstrap: widget.bootstrap,
themeMode: _themeMode,
onThemeModeChanged: (mode) => setState(() => _themeMode = mode),
),
home: HomeScreen(bootstrap: bootstrap),
);
}
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'theme.dart';
import 'workout_execution_screen.dart';
final class HistoryListScreen extends StatefulWidget {
@ -62,7 +63,9 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
),
),
for (final history in group.value)
ListTile(
CourtBlazerAccentPanel(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: ListTile(
title: Text(history.nameSnapshot),
subtitle: Text(
'${_formatDateTime(history.startedAt)} · '
@ -72,6 +75,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => _openDetail(history),
),
),
],
],
);

View File

@ -5,13 +5,21 @@ import '../domain/domain.dart';
import 'exercise_library_screen.dart';
import 'history_screen.dart';
import 'program_screen.dart';
import 'theme.dart';
import 'workout_execution_screen.dart';
import 'workout_template_screen.dart';
final class HomeScreen extends StatefulWidget {
const HomeScreen({required this.bootstrap, super.key});
const HomeScreen({
required this.bootstrap,
this.themeMode = ThemeMode.system,
this.onThemeModeChanged,
super.key,
});
final AppBootstrap bootstrap;
final ThemeMode themeMode;
final ValueChanged<ThemeMode>? onThemeModeChanged;
@override
State<HomeScreen> createState() => _HomeScreenState();
@ -29,7 +37,22 @@ final class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('GameTime')),
appBar: AppBar(
title: const GameTimeLogo(),
actions: [
PopupMenuButton<ThemeMode>(
tooltip: 'Thème',
initialValue: widget.themeMode,
icon: const Icon(Icons.contrast),
onSelected: widget.onThemeModeChanged,
itemBuilder: (context) => const [
PopupMenuItem(value: ThemeMode.system, child: Text('Système')),
PopupMenuItem(value: ThemeMode.light, child: Text('Clair')),
PopupMenuItem(value: ThemeMode.dark, child: Text('Sombre')),
],
),
],
),
body: ListView(
children: [
FutureBuilder<ActiveWorkoutSession?>(
@ -46,7 +69,9 @@ final class _HomeScreenState extends State<HomeScreen> {
setIndex: session.currentSetIndex,
);
final exercise = plan.exerciseAt(position);
return MaterialBanner(
return CourtBlazerAccentPanel(
margin: const EdgeInsets.all(12),
child: MaterialBanner(
content: Text(
'Séance en cours — Reprendre · ${exercise.name}, '
'série ${position.setIndex + 1}/${exercise.setsCount}',
@ -57,6 +82,7 @@ final class _HomeScreenState extends State<HomeScreen> {
child: const Text('Reprendre'),
),
],
),
);
},
),

View File

@ -3,5 +3,6 @@ export 'exercise_library_screen.dart';
export 'history_screen.dart';
export 'home_screen.dart';
export 'program_screen.dart';
export 'theme.dart';
export 'workout_execution_screen.dart';
export 'workout_template_screen.dart';

View File

@ -181,10 +181,7 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'Nom'),
validator: (value) => value == null || value.trim().isEmpty
? 'Le nom est obligatoire.'
: null,
@ -194,7 +191,6 @@ final class _ProgramFormScreenState extends State<ProgramFormScreen> {
controller: _defaultRestController,
decoration: const InputDecoration(
labelText: 'Repos par défaut (s)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: _nonNegativeIntValidator,
@ -356,7 +352,6 @@ final class _ExerciseSelectionScreenState
decoration: const InputDecoration(
labelText: 'Rechercher',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
@ -467,10 +462,7 @@ final class _ProgramExerciseCard extends StatelessWidget {
const SizedBox(height: 12),
TextFormField(
initialValue: '${draft.setsCount}',
decoration: const InputDecoration(
labelText: 'Nombre de séries',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'Nombre de séries'),
keyboardType: TextInputType.number,
onChanged: (value) {
draft.setsCount = int.tryParse(value) ?? draft.setsCount;
@ -530,7 +522,6 @@ final class _ProgramExerciseCard extends StatelessWidget {
decoration: const InputDecoration(
labelText: 'Repos après chaque série (s)',
helperText: 'Pas de repos après la toute dernière série.',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) {
@ -571,10 +562,7 @@ final class _NumberField extends StatelessWidget {
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
initialValue: initialValue?.toString(),
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
decoration: InputDecoration(labelText: label),
keyboardType: TextInputType.number,
onChanged: (value) => onChanged(double.tryParse(value)),
),

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;
}
}

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'history_screen.dart';
import 'theme.dart';
enum WorkoutExecutionMode { active, rest, paused, finished }
@ -146,7 +147,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
Center(
child: Text(
_formatDuration(Duration(seconds: _remainingRestSeconds)),
style: Theme.of(context).textTheme.displayLarge,
style: AppTextStyles.timer(context),
),
),
if (next != null && nextExercise != null) ...[
@ -533,7 +534,7 @@ final class SetMeasureInput extends StatelessWidget {
exercise.targetTimeSeconds == null
? 'Chronométrer'
: '${exercise.targetTimeSeconds} s',
style: Theme.of(context).textTheme.displaySmall,
style: AppTextStyles.scoreNumber(context),
),
],
),
@ -549,7 +550,7 @@ final class SetMeasureInput extends StatelessWidget {
onRepsChanged((reps - 1).clamp(0, 999).toInt()),
icon: const Icon(Icons.remove),
),
Text('$reps'),
Text('$reps', style: AppTextStyles.scoreNumber(context)),
IconButton(
onPressed: () => onRepsChanged(reps + 1),
icon: const Icon(Icons.add),
@ -561,11 +562,11 @@ final class SetMeasureInput extends StatelessWidget {
const SizedBox(height: 12),
TextField(
controller: scoreController,
style: AppTextStyles.scoreNumber(context),
decoration: InputDecoration(
labelText: exercise.scoreUnit == null
? 'Score'
: 'Score (${exercise.scoreUnit})',
border: const OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
@ -770,13 +771,16 @@ final class _Header extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(elapsedLabel, style: Theme.of(context).textTheme.headlineSmall),
Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)),
const SizedBox(height: 4),
Text(progressLabel),
],
),
);
}
}

View File

@ -205,10 +205,7 @@ final class _WorkoutTemplateFormScreenState
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
decoration: const InputDecoration(labelText: 'Nom'),
validator: (value) => value == null || value.trim().isEmpty
? 'Le nom est obligatoire.'
: null,
@ -420,7 +417,6 @@ final class _WorkoutTemplateProgramDetailScreenState
'${exercise.setsCountOverride ?? exercise.setsCount}',
decoration: const InputDecoration(
labelText: 'Nombre de séries',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onChanged: (value) {
@ -633,10 +629,7 @@ final class _NumberField extends StatelessWidget {
padding: const EdgeInsets.only(top: 8),
child: TextFormField(
initialValue: initialValue?.toString(),
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
decoration: InputDecoration(labelText: label),
keyboardType: TextInputType.number,
onChanged: (value) => onChanged(double.tryParse(value)),
),

View File

@ -27,3 +27,10 @@ dev_dependencies:
flutter:
uses-material-design: true
fonts:
- family: Anton
fonts:
- asset: assets/fonts/Anton-Regular.ttf
- family: Archivo
fonts:
- asset: assets/fonts/Archivo-Variable.ttf