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:
@ -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),
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
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;
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user