diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index 7553aea..25dee3f 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -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 diff --git a/.ideai/memory/gametime-visual-identity.md b/.ideai/memory/gametime-visual-identity.md new file mode 100644 index 0000000..46a93a0 --- /dev/null +++ b/.ideai/memory/gametime-visual-identity.md @@ -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+). \ No newline at end of file diff --git a/.ideai/tickets/17/carnet.md b/.ideai/tickets/17/carnet.md new file mode 100644 index 0000000..5f65c76 --- /dev/null +++ b/.ideai/tickets/17/carnet.md @@ -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). \ No newline at end of file diff --git a/.ideai/tickets/17/issue.md b/.ideai/tickets/17/issue.md new file mode 100644 index 0000000..c308a00 --- /dev/null +++ b/.ideai/tickets/17/issue.md @@ -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). \ No newline at end of file diff --git a/.ideai/tickets/18/carnet.md b/.ideai/tickets/18/carnet.md new file mode 100644 index 0000000..0169327 --- /dev/null +++ b/.ideai/tickets/18/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#18" +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1784324398610 +--- diff --git a/.ideai/tickets/18/issue.md b/.ideai/tickets/18/issue.md new file mode 100644 index 0000000..baac10c --- /dev/null +++ b/.ideai/tickets/18/issue.md @@ -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 \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 87512f6..621af3b 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 16 + "nextNumber": 19 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 4e499c6..edda546 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -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 } ] } \ No newline at end of file diff --git a/assets/fonts/Anton-Regular.ttf b/assets/fonts/Anton-Regular.ttf new file mode 100644 index 0000000..4d65707 Binary files /dev/null and b/assets/fonts/Anton-Regular.ttf differ diff --git a/assets/fonts/Archivo-Variable.ttf b/assets/fonts/Archivo-Variable.ttf new file mode 100644 index 0000000..cc64253 Binary files /dev/null and b/assets/fonts/Archivo-Variable.ttf differ diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index 3bbdda4..07c9945 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -97,7 +97,6 @@ final class _ExerciseLibraryScreenState extends State { decoration: const InputDecoration( labelText: 'Rechercher', prefixIcon: Icon(Icons.search), - border: OutlineInputBorder(), ), ), const SizedBox(height: 12), @@ -339,10 +338,7 @@ final class _ExerciseFormScreenState extends State { 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 { 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 { 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 { 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), diff --git a/lib/presentation/game_time_app.dart b/lib/presentation/game_time_app.dart index 379a2b4..d3ff767 100644 --- a/lib/presentation/game_time_app.dart +++ b/lib/presentation/game_time_app.dart @@ -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 createState() => _GameTimeAppState(); +} + +final class _GameTimeAppState extends State { + 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), ); } } diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index 9b29925..f5942c0 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -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,15 +63,18 @@ final class _HistoryListScreenState extends State { ), ), for (final history in group.value) - ListTile( - title: Text(history.nameSnapshot), - subtitle: Text( - '${_formatDateTime(history.startedAt)} · ' - '${_formatDurationMs(history.totalActiveMs)} · ' - '${_historySummary(history)}', + CourtBlazerAccentPanel( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: ListTile( + title: Text(history.nameSnapshot), + subtitle: Text( + '${_formatDateTime(history.startedAt)} · ' + '${_formatDurationMs(history.totalActiveMs)} · ' + '${_historySummary(history)}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openDetail(history), ), - trailing: const Icon(Icons.chevron_right), - onTap: () => _openDetail(history), ), ], ], diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 36b4d0a..a5b9fb5 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -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? onThemeModeChanged; @override State createState() => _HomeScreenState(); @@ -29,7 +37,22 @@ final class _HomeScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('GameTime')), + appBar: AppBar( + title: const GameTimeLogo(), + actions: [ + PopupMenuButton( + 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( @@ -46,17 +69,20 @@ final class _HomeScreenState extends State { setIndex: session.currentSetIndex, ); final exercise = plan.exerciseAt(position); - return MaterialBanner( - content: Text( - 'Séance en cours — Reprendre · ${exercise.name}, ' - 'série ${position.setIndex + 1}/${exercise.setsCount}', - ), - actions: [ - TextButton( - onPressed: () => _resume(session), - child: const Text('Reprendre'), + 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}', ), - ], + actions: [ + TextButton( + onPressed: () => _resume(session), + child: const Text('Reprendre'), + ), + ], + ), ); }, ), diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index 056e35d..af12a86 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -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'; diff --git a/lib/presentation/program_screen.dart b/lib/presentation/program_screen.dart index a3b16c7..c0690ac 100644 --- a/lib/presentation/program_screen.dart +++ b/lib/presentation/program_screen.dart @@ -181,10 +181,7 @@ final class _ProgramFormScreenState extends State { 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 { 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)), ), diff --git a/lib/presentation/theme.dart b/lib/presentation/theme.dart new file mode 100644 index 0000000..7b8369f --- /dev/null +++ b/lib/presentation/theme.dart @@ -0,0 +1,436 @@ +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; + } +} diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index c907794..f386915 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -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 { 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( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(elapsedLabel, style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 4), - Text(progressLabel), - ], + return CourtBlazerAccentPanel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)), + const SizedBox(height: 4), + Text(progressLabel), + ], + ), ); } } diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index f71490c..9666990 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -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)), ), diff --git a/pubspec.yaml b/pubspec.yaml index 8ae3487..cba2832 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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