workout_app: warm-tint the unified-design-system palette
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.12) (push) Has been cancelled

Replaces the dead ColorScheme.fromSeed(Colors.indigo) (declared but
never consumed by any screen/widget) with theme.dart: an explicit
dark ColorScheme plus a real AppStatusColors extension, since this
app genuinely uses success/warning status colors throughout (unlike
dufs-cloud's app). Routes all 10 screen/widget files' ~280 hardcoded
Colors.*/fontSize literals through it. Fixes the two flagged
DESIGN_AUDIT_TODO violations: home_screen's Resume/Start buttons now
use a real 2x horizontal:vertical padding ratio, and rep_circle's
BoxShadow (the one shadow in an otherwise flat dark UI) is removed.
This commit is contained in:
Krzysztof Rudnicki 2026-07-22 21:08:45 +02:00
parent 0c6f6957a8
commit 323e3cd02a
22 changed files with 927 additions and 483 deletions

View File

@ -5,6 +5,7 @@ import 'package:workout_app/screens/home_screen.dart';
import 'package:workout_app/services/backup_service.dart';
import 'package:workout_app/services/http_server_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
// coverage:ignore-start
// App bootstrap: permission request, DB init, and a real listening socket —
@ -65,13 +66,7 @@ class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
return MaterialApp(
title: 'Workout Tracker',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
useMaterial3: true,
),
theme: buildAppTheme(),
home: const HomeScreen(),
);
}

View File

@ -13,6 +13,7 @@ import 'package:http/http.dart' as http;
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/calendar_widget.dart';
const _kTotal = 'Total (all workouts)';
@ -96,10 +97,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
final payloads = await WorkoutSyncService(
httpClient: widget.httpClient,
).readMergedWorkoutPayloads();
final synced = payloads
.where((p) => _kSyncedOnlyKinds.contains(p['kind']))
.toList()
..sort((a, b) => '${b['date']}'.compareTo('${a['date']}'));
final synced =
payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList()
..sort((a, b) => '${b['date']}'.compareTo('${a['date']}'));
return synced;
}
@ -202,20 +202,23 @@ class _HistoryScreenState extends State<HistoryScreen> {
final allNames = [_kTotal, ..._exerciseNames];
final isTotal = _selected == _kTotal;
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: Colors.grey.shade900,
appBar: AppBar(
backgroundColor: Colors.grey.shade800,
title: const Text('Progress', style: TextStyle(color: Colors.white)),
iconTheme: const IconThemeData(color: Colors.white),
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Progress',
style: TextStyle(color: colorScheme.onSurface),
),
iconTheme: IconThemeData(color: colorScheme.onSurface),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _rows.isEmpty
? const Center(
? Center(
child: Text(
'No workouts yet.',
style: TextStyle(color: Colors.white54),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
)
: ListView(
@ -327,9 +330,9 @@ class _SectionLabel extends StatelessWidget {
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
color: Colors.white54,
fontSize: 11,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
letterSpacing: 1.3,
),
);
@ -349,10 +352,11 @@ class _ExercisePicker extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return DropdownButton<String>(
value: selected,
dropdownColor: Colors.grey.shade800,
style: const TextStyle(color: Colors.white),
dropdownColor: colorScheme.surfaceContainerHigh,
style: TextStyle(color: colorScheme.onSurface),
underline: const SizedBox(),
isExpanded: true,
items: names
@ -362,7 +366,9 @@ class _ExercisePicker extends StatelessWidget {
child: Text(
n,
style: TextStyle(
color: n == _kTotal ? Colors.white70 : Colors.white,
color: n == _kTotal
? colorScheme.onSurfaceVariant
: colorScheme.onSurface,
fontStyle: n == _kTotal ? FontStyle.italic : FontStyle.normal,
),
),
@ -395,27 +401,29 @@ class _ProgressStatsCard extends StatelessWidget {
final successLeft = state.successThreshold - state.successStreak;
final failLeft = state.failThreshold - state.failStreak;
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(8),
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${state.name} — ${state.weight}kg',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 14,
fontSize: AppTextSize.label,
),
),
const SizedBox(height: 8),
_StreakRow(
icon: Icons.trending_up,
color: Colors.greenAccent,
color: status.success,
current: state.successStreak,
threshold: state.successThreshold,
leftLabel: '$successLeft more',
@ -429,7 +437,7 @@ class _ProgressStatsCard extends StatelessWidget {
const SizedBox(height: 6),
_StreakRow(
icon: Icons.trending_down,
color: Colors.redAccent,
color: colorScheme.error,
current: state.failStreak,
threshold: state.failThreshold,
leftLabel: '$failLeft more',
@ -463,6 +471,7 @@ class _StreakRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Icon(icon, color: color, size: 14),
@ -475,7 +484,7 @@ class _StreakRow extends StatelessWidget {
margin: const EdgeInsets.only(right: 3),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i < current ? color : Colors.white24,
color: i < current ? color : colorScheme.surfaceContainerHighest,
),
),
),
@ -483,7 +492,10 @@ class _StreakRow extends StatelessWidget {
Expanded(
child: Text(
'$leftLabel to $direction $actionLabel',
style: const TextStyle(color: Colors.white60, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
),
],
@ -498,20 +510,25 @@ class _WeightChart extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (points.length < 2) {
return Container(
height: 80,
alignment: Alignment.center,
child: const Text(
child: Text(
'Not enough data for chart',
style: TextStyle(color: Colors.white38),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
);
}
return SizedBox(
height: 140,
child: CustomPaint(
painter: _ChartPainter(points),
painter: _ChartPainter(
points,
lineColor: colorScheme.primary,
labelColor: colorScheme.onSurfaceVariant,
),
size: Size.infinite,
),
);
@ -519,10 +536,19 @@ class _WeightChart extends StatelessWidget {
}
class _ChartPainter extends CustomPainter {
_ChartPainter(this.points);
_ChartPainter(
this.points, {
required this.lineColor,
required this.labelColor,
});
final List<(DateTime, double)> points;
// CustomPainter.paint() has no BuildContext, so the caller (which does)
// passes the themed colors in explicitly.
final Color lineColor;
final Color labelColor;
// Layout constants
static const _topPad = 14.0; // room for top Y label
static const _bottomPad = 22.0; // room for X-axis dates
@ -569,11 +595,11 @@ class _ChartPainter extends CustomPainter {
: (1 - (w - minW) / wRange) * plotHeight + plotTop;
final linePaint = Paint()
..color = Colors.indigoAccent
..color = lineColor
..strokeWidth = 2
..style = PaintingStyle.stroke;
final dotPaint = Paint()
..color = Colors.indigoAccent
..color = lineColor
..style = PaintingStyle.fill;
final path = Path()..moveTo(xOf(points.first.$1), yOf(points.first.$2));
@ -591,7 +617,7 @@ class _ChartPainter extends CustomPainter {
tp
..text = TextSpan(
text: text,
style: TextStyle(color: Colors.white54, fontSize: fontSize),
style: TextStyle(color: labelColor, fontSize: fontSize),
)
..layout()
..paint(canvas, offset);
@ -609,7 +635,7 @@ class _ChartPainter extends CustomPainter {
tp
..text = TextSpan(
text: label,
style: const TextStyle(color: Colors.white38, fontSize: 9),
style: TextStyle(color: labelColor, fontSize: 9),
)
..layout();
final cx = xOf(p.$1);
@ -618,6 +644,8 @@ class _ChartPainter extends CustomPainter {
}
}
// lineColor/labelColor come from the app's single fixed dark theme, so
// they never actually change at runtime — only points varies.
@override
bool shouldRepaint(_ChartPainter old) => old.points != points;
}
@ -630,6 +658,7 @@ class _SyncedWorkoutTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final kind = '${payload['kind']}';
final isRun = kind == 'runnerup_verified';
final label = isRun ? 'Run' : 'Manual';
@ -639,17 +668,17 @@ class _SyncedWorkoutTile extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isRun ? Colors.blue.shade800 : Colors.orange.shade900,
),
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
// Neutral — this distinguishes entry TYPE (run vs. manual), not a
// status/judgment, so it doesn't borrow a semantic status color.
border: Border.all(color: colorScheme.outline),
),
child: Row(
children: [
Icon(
isRun ? Icons.directions_run : Icons.edit_note,
color: isRun ? Colors.blue.shade300 : Colors.orange.shade300,
color: colorScheme.onSurfaceVariant,
size: 20,
),
const SizedBox(width: 10),
@ -659,15 +688,18 @@ class _SyncedWorkoutTile extends StatelessWidget {
children: [
Text(
'${payload['date']} · $label',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
if (detail.isNotEmpty)
Text(
detail,
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
),
@ -692,26 +724,27 @@ class _AllSessionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final succeeded = (row['succeeded'] as int) == 1;
final type = row['workout_type'] as String;
final date = row['date'] as String;
final dur = _formatDuration(row['duration_seconds'] as int);
final statusColor = succeeded ? status.success : colorScheme.error;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
),
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: statusColor),
),
child: Row(
children: [
Icon(
succeeded ? Icons.check_circle : Icons.cancel,
color: succeeded ? Colors.greenAccent : Colors.redAccent,
color: statusColor,
size: 18,
),
const SizedBox(width: 10),
@ -721,14 +754,17 @@ class _AllSessionTile extends StatelessWidget {
children: [
Text(
'Workout $type · $date',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Text(
dur,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
),
@ -753,6 +789,8 @@ class _ExerciseSessionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final exData = session['exerciseData'] as Map<String, dynamic>;
final succeeded = (exData['succeeded'] as bool?) == true;
final date = session['date'] as String;
@ -763,16 +801,19 @@ class _ExerciseSessionTile extends StatelessWidget {
final targetSets = exData['targetSets'] as int? ?? sets.length;
final doneSets = sets.where((s) => s['succeeded'] == true).length;
final repsSummary = sets.map((s) => '${s['doneReps']}').join(', ');
final statusColor = succeeded ? status.success : colorScheme.error;
final mutedStyle = TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
);
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: succeeded ? Colors.green.shade800 : Colors.red.shade900,
),
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: statusColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -781,7 +822,7 @@ class _ExerciseSessionTile extends StatelessWidget {
padding: const EdgeInsets.only(top: 2),
child: Icon(
succeeded ? Icons.check_circle : Icons.cancel,
color: succeeded ? Colors.greenAccent : Colors.redAccent,
color: statusColor,
size: 18,
),
),
@ -792,8 +833,8 @@ class _ExerciseSessionTile extends StatelessWidget {
children: [
Text(
date,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
@ -801,29 +842,14 @@ class _ExerciseSessionTile extends StatelessWidget {
Text(
'${weight ?? '?'}kg · $doneSets/$targetSets sets'
' · ${warmupDone ? '⬤ warmup' : '○ no warmup'}',
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
style: mutedStyle,
),
if (repsSummary.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'reps: $repsSummary',
style: const TextStyle(
color: Colors.white54,
fontSize: 11,
),
),
Text('reps: $repsSummary', style: mutedStyle),
],
const SizedBox(height: 2),
Text(
'workout: $dur',
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
),
),
Text('workout: $dur', style: mutedStyle),
],
),
),

View File

@ -10,6 +10,7 @@ import 'package:workout_app/screens/settings_screen.dart';
import 'package:workout_app/screens/workout_screen.dart';
import 'package:workout_app/services/http_server_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
/// Home screen: auto-resumes active sessions and shows done-today status.
class HomeScreen extends StatefulWidget {
@ -100,17 +101,17 @@ class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: Colors.grey.shade900,
appBar: AppBar(
backgroundColor: Colors.grey.shade800,
title: const Text(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Workout Tracker',
style: TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
),
actions: [
IconButton(
icon: const Icon(Icons.edit_note, color: Colors.white),
icon: Icon(Icons.edit_note, color: colorScheme.onSurface),
tooltip: 'Log manual workout',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
@ -119,13 +120,13 @@ class _HomeScreenState extends State<HomeScreen> {
),
),
IconButton(
icon: const Icon(Icons.history, color: Colors.white),
icon: Icon(Icons.history, color: colorScheme.onSurface),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const HistoryScreen()),
),
),
IconButton(
icon: const Icon(Icons.settings, color: Colors.white),
icon: Icon(Icons.settings, color: colorScheme.onSurface),
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
@ -182,24 +183,29 @@ class _WorkoutCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
// Canonical button padding (rule 22): vertical 12, horizontal 24 — an
// exact 2x ratio, both on the 4px spacing scale (tokens.md's own example).
const buttonPadding = EdgeInsets.symmetric(horizontal: 24, vertical: 12);
return Card(
color: Colors.grey.shade800,
color: colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (doneToday && !hasActiveSession) ...[
const Row(
Row(
children: [
Icon(Icons.check_circle, color: Colors.greenAccent, size: 18),
SizedBox(width: 8),
Icon(Icons.check_circle, color: status.success, size: 18),
const SizedBox(width: 8),
Text(
'Done for today!',
style: TextStyle(
color: Colors.greenAccent,
color: status.success,
fontWeight: FontWeight.bold,
fontSize: 15,
fontSize: AppTextSize.body,
),
),
],
@ -207,9 +213,9 @@ class _WorkoutCard extends StatelessWidget {
const SizedBox(height: 6),
Text(
'Next: Workout $type — tomorrow',
style: const TextStyle(
color: Colors.white,
fontSize: 17,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
@ -219,8 +225,10 @@ class _WorkoutCard extends StatelessWidget {
? 'Workout $type in progress'
: 'Next: Workout $type',
style: TextStyle(
color: hasActiveSession ? Colors.orangeAccent : Colors.white,
fontSize: 20,
color: hasActiveSession
? status.warning
: colorScheme.onSurface,
fontSize: AppTextSize.subtitle,
fontWeight: FontWeight.bold,
),
),
@ -229,7 +237,10 @@ class _WorkoutCard extends StatelessWidget {
...exercises.map(
(e) => Text(
'${e.name} ${e.sets}×${e.reps}×${e.weight}kg',
style: const TextStyle(color: Colors.white70, fontSize: 13),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
const SizedBox(height: 14),
@ -238,15 +249,15 @@ class _WorkoutCard extends StatelessWidget {
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange.shade800,
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: status.warning,
padding: buttonPadding,
),
onPressed: onResume,
child: const Text(
child: Text(
'Resume Workout',
style: TextStyle(
color: Colors.white,
fontSize: 16,
color: colorScheme.onPrimary,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
@ -257,15 +268,15 @@ class _WorkoutCard extends StatelessWidget {
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: colorScheme.primary,
padding: buttonPadding,
),
onPressed: onStart,
child: Text(
'Start Workout $type',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
@ -290,6 +301,7 @@ class ServerAddressTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final lines = addresses.isEmpty
? ['Server not started']
: addresses.map((ip) => '$ip:$kWorkoutServerPort').toList();
@ -297,17 +309,17 @@ class ServerAddressTile extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(8),
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'HTTP sync (no ADB needed)',
style: TextStyle(
color: Colors.white54,
fontSize: 11,
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
letterSpacing: 1.1,
),
),
@ -315,10 +327,10 @@ class ServerAddressTile extends StatelessWidget {
...lines.map(
(line) => Text(
line,
style: const TextStyle(
color: Colors.white70,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontSize: 13,
fontSize: AppTextSize.label,
),
),
),

View File

@ -13,6 +13,7 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:workout_app/models/manual_workout.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/ui/theme.dart';
/// A screen that logs an off-app workout and syncs it to the PC.
class ManualWorkoutScreen extends StatefulWidget {
@ -148,15 +149,15 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final budget = _budget;
final exhausted = budget?.exhausted ?? false;
return Scaffold(
backgroundColor: Colors.grey.shade900,
appBar: AppBar(
backgroundColor: Colors.grey.shade800,
title: const Text(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Log Manual Workout',
style: TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
),
),
body: !_budgetLoaded
@ -166,11 +167,11 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
children: [
_budgetBanner(budget!),
if (exhausted)
const Padding(
padding: EdgeInsets.only(top: 8),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Manual-workout budget exhausted for this window.',
style: TextStyle(color: Color(0xFFFF4444)),
style: TextStyle(color: colorScheme.error),
),
)
else ...[
@ -198,7 +199,7 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
_error!,
style: const TextStyle(color: Color(0xFFFF4444)),
style: TextStyle(color: colorScheme.error),
),
),
Padding(
@ -235,16 +236,19 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
Widget _budgetBanner(ManualBudget budget) => Text(
'Manual: ${budget.week}/$kManualWorkoutBudgetPer7Days this week · '
'${budget.month}/$kManualWorkoutBudgetPer30Days this month',
style: const TextStyle(color: Color(0xFF88CCFF), fontSize: 16),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: AppTextSize.body,
),
);
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Text(
title,
style: const TextStyle(
color: Color(0xFF88CCFF),
fontSize: 18,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: AppTextSize.subtitle,
fontWeight: FontWeight.bold,
),
),
@ -254,8 +258,8 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
padding: const EdgeInsets.symmetric(vertical: 4),
child: DropdownButton<String>(
value: _sport,
dropdownColor: Colors.grey.shade800,
style: const TextStyle(color: Colors.white),
dropdownColor: Theme.of(context).colorScheme.surfaceContainerHigh,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
isExpanded: true,
items: [
for (final entry in kSportLabels.entries)
@ -273,7 +277,7 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
children: [
Text(
'RPE ${_rpe.round()}',
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
Expanded(
child: Slider(
@ -295,13 +299,14 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
key: Key('mw_$key'),
controller: _fields[key],
maxLines: lines,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
// filled/fillColor/border inherit from the shared inputDecorationTheme
// (theme.dart) — only the field-specific label needs setting here.
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: Colors.grey.shade400),
filled: true,
fillColor: Colors.grey.shade800,
border: const OutlineInputBorder(),
labelStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);

View File

@ -13,6 +13,7 @@ import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/services/github_device_auth.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_settings.dart';
import 'package:workout_app/ui/theme.dart';
/// How to style a [_SyncStatusBadge].
enum _SyncStatusKind { success, pending, error }
@ -222,33 +223,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Future<void> _resetToDefaults() async {
final colorScheme = Theme.of(context).colorScheme;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
backgroundColor: Colors.grey.shade900,
title: const Text(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Reset to defaults?',
style: TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
),
content: const Text(
content: Text(
'All weights and thresholds will be reset. '
'Streak counters will be cleared.',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
child: Text(
'Cancel',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Reset',
style: TextStyle(color: Colors.redAccent),
),
child: Text('Reset', style: TextStyle(color: colorScheme.error)),
),
],
),
@ -271,18 +270,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: Colors.grey.shade900,
appBar: AppBar(
backgroundColor: Colors.grey.shade800,
title: const Text('Settings', style: TextStyle(color: Colors.white)),
iconTheme: const IconThemeData(color: Colors.white),
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Settings',
style: TextStyle(color: colorScheme.onSurface),
),
iconTheme: IconThemeData(color: colorScheme.onSurface),
actions: [
TextButton(
onPressed: _loading ? null : _resetToDefaults,
child: const Text(
child: Text(
'Reset defaults',
style: TextStyle(color: Colors.redAccent),
style: TextStyle(color: colorScheme.error),
),
),
],
@ -294,10 +296,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
const _SectionHeader('WEIGHTS'),
const SizedBox(height: 4),
const Text(
Text(
'Override current working weight. '
'Resets streak counters. Rounded to 2.5 kg.',
style: TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
const SizedBox(height: 12),
..._orderedNames.map((name) {
@ -312,10 +317,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 20),
const _SectionHeader('PROGRESSION THRESHOLDS'),
const SizedBox(height: 4),
const Text(
Text(
'Consecutive successes (↑) or failures (↓) '
'before weight changes.',
style: TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
const SizedBox(height: 12),
..._orderedNames.map((name) {
@ -334,11 +342,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 20),
const _SectionHeader('GITHUB SYNC'),
const SizedBox(height: 4),
const Text(
Text(
'Authorize in your browser -- no token to paste. Syncs to '
'$syncRepoOwner/$syncRepoName. Workouts push automatically '
'on completion.',
style: TextStyle(color: Colors.white54, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
const SizedBox(height: 12),
if (_syncStatus != null) ...[
@ -352,12 +363,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
ExpansionTile(
title: const Text(
title: Text(
'Advanced: paste a token instead',
style: TextStyle(color: Colors.white70, fontSize: 13),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
collapsedIconColor: Colors.white54,
iconColor: Colors.white54,
collapsedIconColor: colorScheme.onSurfaceVariant,
iconColor: colorScheme.onSurfaceVariant,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(top: 8, bottom: 8),
children: [
@ -382,9 +396,9 @@ class _SectionHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
color: Colors.white54,
fontSize: 11,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
letterSpacing: 1.4,
),
);
@ -404,6 +418,7 @@ class _WeightRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
@ -411,7 +426,10 @@ class _WeightRow extends StatelessWidget {
Expanded(
child: Text(
name,
style: const TextStyle(color: Colors.white70, fontSize: 13),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
_StepperButton(
@ -426,9 +444,9 @@ class _WeightRow extends StatelessWidget {
child: Text(
'${weight}kg',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.label,
fontWeight: FontWeight.bold,
),
),
@ -451,17 +469,18 @@ class _StepperButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.grey.shade700,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Icon(icon, color: Colors.white, size: 18),
child: Icon(icon, color: colorScheme.onSurface, size: 18),
),
);
}
@ -478,10 +497,12 @@ class _SyncStatusBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final color = switch (kind) {
_SyncStatusKind.success => Colors.greenAccent,
_SyncStatusKind.error => Colors.redAccent,
_SyncStatusKind.pending => Colors.white70,
_SyncStatusKind.success => status.success,
_SyncStatusKind.error => colorScheme.error,
_SyncStatusKind.pending => colorScheme.onSurfaceVariant,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
@ -512,7 +533,7 @@ class _SyncStatusBadge extends StatelessWidget {
text,
style: TextStyle(
color: color,
fontSize: 13,
fontSize: AppTextSize.label,
fontWeight: FontWeight.w600,
),
),
@ -531,6 +552,7 @@ class _SyncTokenField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -538,16 +560,13 @@ class _SyncTokenField extends StatelessWidget {
child: TextField(
controller: controller,
obscureText: true,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
// filled/fillColor/border inherit from the shared
// inputDecorationTheme (theme.dart) — only the field-specific
// hint/padding need setting here.
decoration: InputDecoration(
hintText: 'GitHub PAT',
hintStyle: TextStyle(color: Colors.grey.shade600),
filled: true,
fillColor: Colors.grey.shade800,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
@ -579,36 +598,38 @@ class _ExerciseThresholdCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(8),
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 14,
fontSize: AppTextSize.label,
),
),
const SizedBox(height: 10),
_ThresholdRow(
label: '↑ Increase after N successes',
value: successThreshold,
color: Colors.green,
color: status.success,
onChanged: onSuccessChanged,
),
const SizedBox(height: 6),
_ThresholdRow(
label: '↓ Decrease after N failures',
value: failThreshold,
color: Colors.red,
color: colorScheme.error,
onChanged: onFailChanged,
),
],
@ -632,12 +653,16 @@ class _ThresholdRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
),
const SizedBox(width: 8),
@ -651,15 +676,20 @@ class _ThresholdRow extends StatelessWidget {
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == value ? color : Colors.grey.shade700,
color: i == value
? color
: colorScheme.surfaceContainerHighest,
),
alignment: Alignment.center,
child: Text(
'$i',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
// on-fill on the selected (filled) circle.
color: i == value
? colorScheme.onPrimary
: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 13,
fontSize: AppTextSize.label,
),
),
),
@ -711,57 +741,58 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AlertDialog(
backgroundColor: Colors.grey.shade900,
title: const Text(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Authorize on GitHub',
style: TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Enter this code on GitHub:',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
SelectableText(
widget.device.userCode,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.title,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (_error == null)
const Row(
Row(
children: [
SizedBox(
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
const SizedBox(width: 12),
Expanded(
child: Text(
'Waiting for authorization…',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
],
)
else
Text(_error!, style: const TextStyle(color: Colors.redAccent)),
Text(_error!, style: TextStyle(color: colorScheme.error)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
child: Text(
'Cancel',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
FilledButton.icon(

View File

@ -13,6 +13,7 @@ import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_service.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/break_banner.dart';
import 'package:workout_app/widgets/exercise_tile.dart';
import 'package:workout_app/widgets/workout_summary_dialog.dart';
@ -156,8 +157,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
'breakDurationSecs': _breakDurationSecs,
'breakEndMs': _breakStartTime != null
? _breakStartTime!
.add(Duration(seconds: _breakDurationSecs))
.millisecondsSinceEpoch
.add(Duration(seconds: _breakDurationSecs))
.millisecondsSinceEpoch
: 0,
});
}
@ -193,8 +194,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
if (wasNotTapped) {
_tapped[exIdx][setIdx] = true;
} else {
_doneReps[exIdx][setIdx] =
(_doneReps[exIdx][setIdx] - 1).clamp(0, 999);
_doneReps[exIdx][setIdx] = (_doneReps[exIdx][setIdx] - 1).clamp(0, 999);
_recomputeBreakIfNeeded(exIdx, setIdx);
}
});
@ -284,8 +284,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
if (_breakForExIdx != exIdx || _breakForSetIdx != setIdx) return;
if (_breakForSetIdx == -1) return; // warmup break, never recompute
final succeeded =
_doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
final newDuration = succeeded ? _successBreakSecs : _failBreakSecs;
if (newDuration == _breakDurationSecs) return;
@ -294,8 +293,9 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
_breakDurationSecs = newDuration;
_breakRemaining = newRemaining;
_breakLabel =
succeeded ? 'Rest (3 min — well done!)' : 'Rest (5 min — keep going!)';
_breakLabel = succeeded
? 'Rest (3 min — well done!)'
: 'Rest (5 min — keep going!)';
}
Future<void> _onBreakFinished() async {
@ -347,28 +347,27 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
// ── Finish / Reset ─────────────────────────────────────────────────────────
Future<void> _confirmFinish() async {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
backgroundColor: Colors.grey.shade900,
title: const Text(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Finish workout?',
style: TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
child: Text(
'Cancel',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Finish',
style: TextStyle(color: Colors.greenAccent),
),
child: Text('Finish', style: TextStyle(color: status.success)),
),
],
),
@ -377,32 +376,30 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
}
Future<void> _confirmReset() async {
final colorScheme = Theme.of(context).colorScheme;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
backgroundColor: Colors.grey.shade900,
title: const Text(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Reset workout?',
style: TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
),
content: const Text(
content: Text(
'All progress will be lost.',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
child: Text(
'Cancel',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Reset',
style: TextStyle(color: Colors.redAccent),
),
child: Text('Reset', style: TextStyle(color: colorScheme.error)),
),
],
),
@ -486,27 +483,28 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return PopScope(
// Explicit `canPop: true` makes it clear this scope never blocks the back
// button — a future reader must not assume the default silently.
// ignore: avoid_redundant_argument_values
canPop: true,
child: Scaffold(
backgroundColor: Colors.grey.shade900,
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: Colors.grey.shade800,
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Workout ${widget.workoutType} · ${_formatDuration(_elapsed)}',
style: const TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.onSurface),
),
actions: [
if (!_finished)
TextButton(
onPressed: _confirmReset,
child: const Text(
child: Text(
'Reset',
style: TextStyle(color: Colors.redAccent),
style: TextStyle(color: colorScheme.error),
),
),
if (!_finished)
@ -515,7 +513,9 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
child: Text(
'Finish',
style: TextStyle(
color: _allSetsCompleted ? Colors.greenAccent : Colors.grey,
color: _allSetsCompleted
? status.success
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),

View File

@ -0,0 +1,164 @@
/// The app's design tokens: one identical palette shared across every one
/// of kuhy's apps (Flutter, web, Python/Tkinter alike) — see the
/// `unified-design-system` skill (`~/.claude/skills/unified-design-system/`)
/// for the frozen token table this file implements. Built from an explicit
/// `ColorScheme`, not `ColorScheme.fromSeed`, because the shared palette is
/// hand-picked, not algorithmically derived from one seed color.
library;
import 'package:flutter/material.dart';
/// Builds the app's single dark `ThemeData` from the shared token set.
ThemeData buildAppTheme() {
const colorScheme = ColorScheme.dark(
surface: Color(0xFF211D1B), // ink
surfaceContainerHighest: Color(0xFF38312E), // ink-raised-2
surfaceContainerHigh: Color(0xFF2B2624), // ink-raised-1
onSurface: Color(0xFFECEAE9), // text-on-dark
onSurfaceVariant: Color(0xFFAAA09A), // muted-on-dark
outline: Color(0xFF463E3A), // line-dark
primary: Color(0xFFB8862E), // accent
onPrimary: Color(0xFF211D1B), // on-fill
secondary: Color(0xFFB8862E), // accent
onSecondary: Color(0xFF211D1B), // on-fill
secondaryContainer: Color(0xFF463E3A), // line-dark
onSecondaryContainer: Color(0xFFB8862E), // accent
tertiary: Color(0xFFB8862E), // accent
onTertiary: Color(0xFF211D1B), // on-fill
tertiaryContainer: Color(0xFF463E3A), // line-dark
onTertiaryContainer: Color(0xFFB8862E), // accent
error: Color(0xFFE2585F), // danger
onError: Color(0xFF211D1B), // on-fill
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
extensions: const [AppStatusColors.dark],
textTheme: const TextTheme(
bodyLarge: TextStyle(fontSize: AppTextSize.body),
bodyMedium: TextStyle(fontSize: AppTextSize.body),
titleLarge: TextStyle(fontSize: AppTextSize.title),
titleMedium: TextStyle(fontSize: AppTextSize.subtitle),
labelMedium: TextStyle(fontSize: AppTextSize.label),
labelSmall: TextStyle(fontSize: AppTextSize.caption),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHigh,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
borderSide: BorderSide(color: colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
),
dividerTheme: DividerThemeData(
color: colorScheme.outline,
thickness: 1,
space: AppSpacing.md,
),
);
}
/// Semantic status colors M3's [ColorScheme] has no role for (it only has
/// `error`). Success/warning are the shared `unified-design-system` values —
/// every status indicator (set completed, break running, budget pending)
/// should read these instead of an ad hoc `Colors.greenAccent`/`orangeAccent`.
@immutable
class AppStatusColors extends ThemeExtension<AppStatusColors> {
/// Creates a status-color set.
const AppStatusColors({required this.success, required this.warning});
/// The shared dark-theme instance — success/warning from the unified
/// palette (danger already exists as `colorScheme.error`).
static const dark = AppStatusColors(
success: Color(0xFF8A9A3C),
warning: Color(0xFFE0A63C),
);
/// Positive/on-track status (e.g. a completed set, an earned skip).
final Color success;
/// Caution/pending status (e.g. a running break, a near-exhausted budget).
final Color warning;
@override
AppStatusColors copyWith({Color? success, Color? warning}) => AppStatusColors(
success: success ?? this.success,
warning: warning ?? this.warning,
);
@override
AppStatusColors lerp(AppStatusColors? other, double t) {
if (other is! AppStatusColors) return this;
return AppStatusColors(
success: Color.lerp(success, other.success, t) ?? success,
warning: Color.lerp(warning, other.warning, t) ?? warning,
);
}
}
/// Shared spacing scale (4px base) — round any new value to one of these
/// instead of introducing an off-scale literal.
abstract final class AppSpacing {
/// 4px.
static const double xs = 4;
/// 8px.
static const double sm = 8;
/// 16px.
static const double md = 16;
/// 24px.
static const double lg = 24;
/// 32px.
static const double xl = 32;
/// 48px.
static const double xxl = 48;
}
/// Shared corner-radius scale. Nested radii should be `outer - gap`, not a
/// fixed constant — compute per instance per safe-design-rules rule 24.
abstract final class AppRadius {
/// Buttons, inputs, chips.
static const double sm = 8;
/// Cards.
static const double md = 12;
/// Sheets, dialogs.
static const double lg = 16;
}
/// Shared type scale (px). `body` is the floor for anything a user reads;
/// `label`/`caption` are for UI chrome only (timestamps, badges, tags).
abstract final class AppTextSize {
/// 12px — chrome only.
static const double caption = 12;
/// 14px — chrome only.
static const double label = 14;
/// 16px — the floor for actual reading content.
static const double body = 16;
/// 20px.
static const double subtitle = 20;
/// 24px.
static const double title = 24;
/// 32px.
static const double display = 32;
}

View File

@ -2,6 +2,7 @@
library;
import 'package:flutter/material.dart';
import 'package:workout_app/ui/theme.dart';
/// Banner widget showing a break countdown and a skip button.
class BreakBanner extends StatelessWidget {
@ -30,8 +31,12 @@ class BreakBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return Container(
color: Colors.indigo.shade900,
// Elevation via fill step (ink-raised-2), not a shadow — differentiates
// the banner from the page without a saturated attention-grabbing fill.
color: colorScheme.surfaceContainerHighest,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
@ -42,15 +47,19 @@ class BreakBanner extends StatelessWidget {
children: [
Text(
breakLabel,
style: const TextStyle(color: Colors.white70, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
Text(
_fmt(breakRemaining),
style: const TextStyle(
color: Colors.white,
fontSize: 26,
style: TextStyle(
// Warning (caution/pending) — this is a running countdown.
color: status.warning,
fontSize: AppTextSize.title,
fontWeight: FontWeight.bold,
fontFeatures: [FontFeature.tabularFigures()],
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
@ -58,10 +67,7 @@ class BreakBanner extends StatelessWidget {
),
TextButton(
onPressed: onSkip,
child: const Text(
'Skip',
style: TextStyle(color: Colors.white70),
),
child: Text('Skip', style: TextStyle(color: colorScheme.primary)),
),
],
),

View File

@ -2,6 +2,7 @@
library;
import 'package:flutter/material.dart';
import 'package:workout_app/ui/theme.dart';
/// Monthly calendar widget that highlights days with completed workouts.
class WorkoutCalendar extends StatelessWidget {
@ -48,6 +49,8 @@ class WorkoutCalendar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final year = month.year;
final m = month.month;
final daysInMonth = DateTime(year, m + 1, 0).day;
@ -59,8 +62,8 @@ class WorkoutCalendar extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(8),
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
children: [
@ -69,21 +72,27 @@ class WorkoutCalendar extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, color: Colors.white70),
icon: Icon(
Icons.chevron_left,
color: colorScheme.onSurfaceVariant,
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
onPressed: onPrevMonth,
),
Text(
'${_monthNames[m - 1]} $year',
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 14,
fontSize: AppTextSize.label,
),
),
IconButton(
icon: const Icon(Icons.chevron_right, color: Colors.white70),
icon: Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
onPressed: onNextMonth,
@ -101,9 +110,9 @@ class WorkoutCalendar extends StatelessWidget {
child: Text(
h,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white38,
fontSize: 11,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
fontWeight: FontWeight.bold,
),
),
@ -131,15 +140,18 @@ class WorkoutCalendar extends StatelessWidget {
decoration: worked
? BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.shade700,
color: status.success,
)
: null,
alignment: Alignment.center,
child: Text(
'$day',
style: TextStyle(
color: worked ? Colors.white : Colors.white38,
fontSize: 12,
// on-fill (dark) on the success circle, muted otherwise.
color: worked
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
fontWeight: worked ? FontWeight.bold : FontWeight.normal,
),
),

View File

@ -3,6 +3,7 @@ library;
import 'package:flutter/material.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/rep_circle.dart';
/// Card widget displaying warmup and working-set rep circles for one exercise.
@ -59,10 +60,20 @@ class ExerciseTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
var headerColor = Colors.grey.shade800;
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
var headerColor = colorScheme.surfaceContainerHigh;
if (_allCompleted) {
headerColor = _allSucceeded ? Colors.green.shade800 : Colors.red.shade900;
headerColor = _allSucceeded ? status.success : colorScheme.error;
}
// A filled success/danger card needs on-fill text throughout (tokens.md:
// one on-fill value for all four fills, never a per-fill judgment call).
final onHeader = _allCompleted
? colorScheme.onPrimary
: colorScheme.onSurface;
final onHeaderMuted = _allCompleted
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant;
return Card(
color: headerColor,
@ -76,16 +87,19 @@ class ExerciseTile extends StatelessWidget {
Expanded(
child: Text(
exercise.name,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: onHeader,
fontWeight: FontWeight.bold,
fontSize: 15,
fontSize: AppTextSize.body,
),
),
),
Text(
'${exercise.sets}×${exercise.reps}×${exercise.weight}kg',
style: const TextStyle(color: Colors.white70, fontSize: 13),
style: TextStyle(
color: onHeaderMuted,
fontSize: AppTextSize.label,
),
),
],
),
@ -112,7 +126,8 @@ class ExerciseTile extends StatelessWidget {
),
),
),
const Divider(color: Colors.white12, height: 20),
// Color inherited from the shared dividerTheme (line-dark).
const Divider(height: 20),
_ThresholdRow(
successThreshold: successThreshold,
failThreshold: failThreshold,
@ -141,41 +156,35 @@ class _ThresholdRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final captionStyle = TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
);
return Row(
children: [
const Icon(Icons.trending_up, size: 13, color: Colors.greenAccent),
Icon(Icons.trending_up, size: 13, color: status.success),
const SizedBox(width: 4),
const Text(
'after',
style: TextStyle(color: Colors.white38, fontSize: 11),
),
Text('after', style: captionStyle),
const SizedBox(width: 6),
_MiniStepper(
value: successThreshold,
onChanged: onSuccessChanged,
),
const SizedBox(width: 4),
const Text(
'↑',
style: TextStyle(color: Colors.white38, fontSize: 11),
),
Text('↑', style: captionStyle),
const Spacer(),
const Icon(Icons.trending_down, size: 13, color: Colors.redAccent),
Icon(Icons.trending_down, size: 13, color: colorScheme.error),
const SizedBox(width: 4),
const Text(
'after',
style: TextStyle(color: Colors.white38, fontSize: 11),
),
Text('after', style: captionStyle),
const SizedBox(width: 6),
_MiniStepper(
value: failThreshold,
onChanged: onFailChanged,
),
const SizedBox(width: 4),
const Text(
'↓',
style: TextStyle(color: Colors.white38, fontSize: 11),
),
Text('↓', style: captionStyle),
],
);
}
@ -192,40 +201,57 @@ class _MiniStepper extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_btn(Icons.remove, value > _min ? () => onChanged(value - 1) : null),
_btn(
context,
Icons.remove,
value > _min ? () => onChanged(value - 1) : null,
),
SizedBox(
width: 22,
child: Text(
'$value',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.caption,
),
),
),
_btn(Icons.add, value < _max ? () => onChanged(value + 1) : null),
_btn(
context,
Icons.add,
value < _max ? () => onChanged(value + 1) : null,
),
],
);
}
Widget _btn(IconData icon, VoidCallback? onTap) => GestureDetector(
onTap: onTap,
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: Colors.grey.shade700,
borderRadius: BorderRadius.circular(4),
Widget _btn(BuildContext context, IconData icon, VoidCallback? onTap) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
alignment: Alignment.center,
child: Icon(
icon,
size: 12,
color: onTap != null
? colorScheme.onSurface
: colorScheme.onSurfaceVariant,
),
),
alignment: Alignment.center,
child: Icon(
icon,
size: 12,
color: onTap != null ? Colors.white : Colors.white24,
),
),
);
);
}
}
class _WarmupRow extends StatelessWidget {
@ -241,16 +267,16 @@ class _WarmupRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final mutedStyle = TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
);
return Row(
children: [
const Text(
'Warmup 1×5×',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
Text(
'${warmupWeight}kg',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
Text('Warmup 1×5×', style: mutedStyle),
Text('${warmupWeight}kg', style: mutedStyle),
const SizedBox(width: 10),
GestureDetector(
onTap: tapped ? null : onTap,
@ -260,16 +286,21 @@ class _WarmupRow extends StatelessWidget {
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: tapped ? Colors.teal : Colors.transparent,
// Tapped = a completed milestone, same semantic as every other
// "done" indicator in this app (rep circles, calendar days).
color: tapped ? status.success : Colors.transparent,
border: Border.all(
color: tapped ? Colors.teal : Colors.white38,
color: tapped ? status.success : colorScheme.outline,
width: 2,
),
),
alignment: Alignment.center,
child: Icon(
tapped ? Icons.check : Icons.fitness_center,
color: tapped ? Colors.white : Colors.white38,
// on-fill on the filled circle; muted on the empty outline one.
color: tapped
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
size: 16,
),
),
@ -278,8 +309,8 @@ class _WarmupRow extends StatelessWidget {
Text(
tapped ? 'done' : 'optional',
style: TextStyle(
color: tapped ? Colors.tealAccent : Colors.white30,
fontSize: 11,
color: tapped ? status.success : colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],

View File

@ -13,6 +13,7 @@
library;
import 'package:flutter/material.dart';
import 'package:workout_app/ui/theme.dart';
/// Visual state of a [RepCircle].
enum RepCircleState {
@ -65,27 +66,29 @@ class RepCircle extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final state = _state;
// on-fill: this circle is a solid filled surface — text on it always
// needs dark (ink) text, never the page's near-white text-on-dark.
final fg = colorScheme.onPrimary;
final Color bg;
final Color fg;
final String label;
switch (state) {
case RepCircleState.neutral:
bg = Colors.white;
fg = Colors.black87;
// Not yet acted upon — a near-white fill (not pure Colors.white)
// still reads as "the bright/blank one" against the dark page.
bg = colorScheme.onSurface;
label = '$targetReps';
case RepCircleState.success:
bg = Colors.green;
fg = Colors.white;
bg = status.success;
label = '$targetReps';
case RepCircleState.partial:
bg = Colors.orange;
fg = Colors.white;
bg = status.warning;
label = '$doneReps';
case RepCircleState.failed:
bg = Colors.red;
fg = Colors.white;
bg = colorScheme.error;
label = '0';
}
@ -99,16 +102,11 @@ class RepCircle extends StatelessWidget {
shape: BoxShape.circle,
color: bg,
border: Border.all(
color: state == RepCircleState.neutral ? Colors.grey.shade400 : bg,
color: state == RepCircleState.neutral ? colorScheme.outline : bg,
width: 2,
),
boxShadow: const [
BoxShadow(
color: Colors.black26,
blurRadius: 4,
offset: Offset(0, 2),
),
],
// No shadow (rule 26: dark surfaces never shadow) — the fill
// color already differentiates this circle from the page.
),
alignment: Alignment.center,
child: Text(
@ -116,7 +114,7 @@ class RepCircle extends StatelessWidget {
style: TextStyle(
color: fg,
fontWeight: FontWeight.bold,
fontSize: 16,
fontSize: AppTextSize.body,
),
),
),

View File

@ -4,6 +4,7 @@ library;
import 'package:flutter/material.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/sync_service.dart';
import 'package:workout_app/ui/theme.dart';
/// Dialog that summarises a completed workout and reports the sync status.
class WorkoutSummaryDialog extends StatelessWidget {
@ -28,13 +29,15 @@ class WorkoutSummaryDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final succeeded = session.fullySucceeded;
return AlertDialog(
backgroundColor: Colors.grey.shade900,
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
succeeded ? 'Workout Complete! 💪' : 'Workout Done',
style: TextStyle(
color: succeeded ? Colors.greenAccent : Colors.orangeAccent,
color: succeeded ? status.success : status.warning,
fontWeight: FontWeight.bold,
),
),
@ -44,14 +47,14 @@ class WorkoutSummaryDialog extends StatelessWidget {
children: [
Text(
'Duration: ${_fmt(session.duration)}',
style: const TextStyle(color: Colors.white70),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
...session.exercises.map(
(e) => Text(
'${e.exercise.name}: ${e.succeeded ? "✓" : "✗"}',
style: TextStyle(
color: e.succeeded ? Colors.greenAccent : Colors.redAccent,
color: e.succeeded ? status.success : colorScheme.error,
),
),
),
@ -61,8 +64,10 @@ class WorkoutSummaryDialog extends StatelessWidget {
? 'Saved to ${syncResult.path}'
: 'Sync failed: ${syncResult.error}',
style: TextStyle(
color: syncResult.success ? Colors.white54 : Colors.redAccent,
fontSize: 12,
color: syncResult.success
? colorScheme.onSurfaceVariant
: colorScheme.error,
fontSize: AppTextSize.caption,
),
),
],
@ -72,9 +77,9 @@ class WorkoutSummaryDialog extends StatelessWidget {
onPressed: () {
Navigator.of(context).popUntil((r) => r.isFirst);
},
child: const Text(
child: Text(
'Back to Home',
style: TextStyle(color: Colors.white),
style: TextStyle(color: colorScheme.primary),
),
),
],

View File

@ -9,6 +9,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/screens/history_screen.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_secure_storage.dart';
@ -35,8 +36,10 @@ void main() {
await tester.pump();
}
Widget _wrap({http.Client? httpClient}) =>
MaterialApp(home: HistoryScreen(httpClient: httpClient));
Widget _wrap({http.Client? httpClient}) => MaterialApp(
theme: buildAppTheme(),
home: HistoryScreen(httpClient: httpClient),
);
/// A GitHub mock serving one PC device log holding a run and a manual.
http.Client _syncMock() {
@ -72,9 +75,12 @@ void main() {
return MockClient((req) async {
final path = req.url.path;
if (path.endsWith('screen-locker-sync/devices')) {
return http.Response(jsonEncode([
{'name': 'pc', 'type': 'dir'},
]), 200);
return http.Response(
jsonEncode([
{'name': 'pc', 'type': 'dir'},
]),
200,
);
}
if (path.contains('pc/log.json')) {
return http.Response(file(log), 200);
@ -136,8 +142,9 @@ void main() {
expect(find.textContaining('Workout A'), findsWidgets);
});
testWidgets('exercise picker shows "Total (all workouts)" initially',
(tester) async {
testWidgets('exercise picker shows "Total (all workouts)" initially', (
tester,
) async {
final json = jsonEncode({
'exercises': [
{
@ -181,8 +188,9 @@ void main() {
expect(find.byIcon(Icons.cancel), findsWidgets);
});
testWidgets('session duration over 1 hour formats with h prefix',
(tester) async {
testWidgets('session duration over 1 hour formats with h prefix', (
tester,
) async {
final json = jsonEncode({'exercises': []});
await _seed(tester, json, duration: 3700);
await _pump(tester, _wrap());
@ -248,7 +256,9 @@ void main() {
});
}
testWidgets('selecting an exercise shows the drill-down view', (tester) async {
testWidgets('selecting an exercise shows the drill-down view', (
tester,
) async {
// Use a real progression-tracked exercise so getExerciseState is non-null
// and the streak stats card renders. Three distinct weights on three dates
// give the weight chart >= 2 points with a real range (painter fully runs).
@ -260,7 +270,13 @@ void main() {
);
await _seed(
tester,
_exerciseSessionJson(name, 22.5, ok: false, warmup: false, withSets: false),
_exerciseSessionJson(
name,
22.5,
ok: false,
warmup: false,
withSets: false,
),
date: '2024-06-08',
succeeded: false,
);
@ -308,23 +324,24 @@ void main() {
expect(find.textContaining('reps:'), findsWidgets);
});
testWidgets('chart handles two points on the same date (tRange == 0)',
(tester) async {
testWidgets('chart handles two points on the same date (tRange == 0)', (
tester,
) async {
// Two sessions on the SAME date give the total chart two points that share
// a timestamp, exercising the painter's tRange == 0 (centre-x) branch.
String vol(double w) => jsonEncode({
'exercises': [
{
'name': 'Squat',
'targetSets': 3,
'targetReps': 5,
'targetWeight': w,
'warmupDone': false,
'succeeded': true,
'sets': <Map<String, dynamic>>[],
},
],
});
'exercises': [
{
'name': 'Squat',
'targetSets': 3,
'targetReps': 5,
'targetWeight': w,
'warmupDone': false,
'succeeded': true,
'sets': <Map<String, dynamic>>[],
},
],
});
await _seed(tester, vol(20), date: '2024-06-01');
await _seed(tester, vol(40), date: '2024-06-01');
await _pump(tester, _wrap());

View File

@ -5,6 +5,7 @@ import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/screens/home_screen.dart';
import 'package:workout_app/screens/workout_screen.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_secure_storage.dart';
@ -29,7 +30,8 @@ void main() {
await tester.pump();
}
Widget _wrap() => const MaterialApp(home: HomeScreen());
Widget _wrap() =>
MaterialApp(theme: buildAppTheme(), home: const HomeScreen());
testWidgets('shows Workout Tracker app bar', (tester) async {
await _pump(tester, _wrap());
@ -62,8 +64,9 @@ void main() {
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('"Done for today" message shows after saving a session today',
(tester) async {
testWidgets('"Done for today" message shows after saving a session today', (
tester,
) async {
final today = DateTime.now();
final dateStr =
'${today.year}-${today.month.toString().padLeft(2, '0')}'
@ -88,7 +91,9 @@ void main() {
expect(find.text('HTTP sync (no ADB needed)'), findsOneWidget);
});
testWidgets('manual-workout icon navigates to the manual form', (tester) async {
testWidgets('manual-workout icon navigates to the manual form', (
tester,
) async {
installFakeSecureStorage(); // ManualWorkoutScreen loads its sync budget
await _pump(tester, _wrap());
await tester.tap(find.byIcon(Icons.edit_note));
@ -97,8 +102,9 @@ void main() {
expect(find.text('Log Manual Workout'), findsOneWidget);
});
testWidgets('starting a workout navigates to the workout screen',
(tester) async {
testWidgets('starting a workout navigates to the workout screen', (
tester,
) async {
await _pump(tester, _wrap());
await tester.tap(find.text('Start Workout A'));
await tester.pump();
@ -106,8 +112,9 @@ void main() {
expect(find.textContaining('Workout A'), findsWidgets);
});
testWidgets('an active session auto-resumes into the workout screen',
(tester) async {
testWidgets('an active session auto-resumes into the workout screen', (
tester,
) async {
await tester.runAsync(
() => StorageService.instance.saveActiveSession({
'workoutType': 'A',
@ -121,8 +128,9 @@ void main() {
expect(find.textContaining('Workout A'), findsWidgets);
});
testWidgets('active session shows Resume, returns (98) and re-enters (153)',
(tester) async {
testWidgets('active session shows Resume, returns (98) and re-enters (153)', (
tester,
) async {
// A fully-formed active session so WorkoutScreen restores cleanly (its
// _restoreFromSaved expects startTimeMs + per-exercise tapped/doneReps).
await tester.runAsync(
@ -130,7 +138,9 @@ void main() {
'workoutType': 'A',
'startTimeMs': DateTime.now().millisecondsSinceEpoch,
'tapped': [for (final e in workoutA) List<bool>.filled(e.sets, false)],
'doneReps': [for (final e in workoutA) List<int>.filled(e.sets, e.reps)],
'doneReps': [
for (final e in workoutA) List<int>.filled(e.sets, e.reps),
],
'warmupTapped': List<bool>.filled(workoutA.length, false),
}),
);
@ -166,8 +176,9 @@ void main() {
expect(find.byType(WorkoutScreen), findsOneWidget);
});
testWidgets('returning from settings reloads the home screen',
(tester) async {
testWidgets('returning from settings reloads the home screen', (
tester,
) async {
installFakeSecureStorage(); // SettingsScreen reads the sync token on init
await _pump(tester, _wrap());
await tester.tap(find.byIcon(Icons.settings));
@ -185,8 +196,9 @@ void main() {
expect(find.text('Workout Tracker'), findsOneWidget);
});
testWidgets('ServerAddressTile shows a placeholder when no addresses',
(tester) async {
testWidgets('ServerAddressTile shows a placeholder when no addresses', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: ServerAddressTile(addresses: <String>[])),

View File

@ -16,6 +16,7 @@ import 'package:workout_app/services/sync_settings.dart';
import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/screens/settings_screen.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_secure_storage.dart';
@ -59,8 +60,10 @@ void main() {
await tester.pump();
}
Widget _wrap({http.Client? httpClient}) =>
MaterialApp(home: SettingsScreen(httpClient: httpClient));
Widget _wrap({http.Client? httpClient}) => MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(httpClient: httpClient),
);
/// Drains the device flow's real `Future.delayed` poll (GitHubDeviceAuth
/// injects no test delay, so under `runAsync` it is a genuine Timer, not
@ -94,14 +97,17 @@ void main() {
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('shows WEIGHTS and PROGRESSION THRESHOLDS sections',
(tester) async {
testWidgets('shows WEIGHTS and PROGRESSION THRESHOLDS sections', (
tester,
) async {
await _pump(tester, _wrap());
expect(find.text('WEIGHTS'), findsOneWidget);
expect(find.text('PROGRESSION THRESHOLDS'), findsOneWidget);
});
testWidgets('shows all exercise names from both workout plans', (tester) async {
testWidgets('shows all exercise names from both workout plans', (
tester,
) async {
await _pump(tester, _wrap());
for (final ex in [...workoutA, ...workoutB]) {
expect(find.text(ex.name), findsWidgets);
@ -169,8 +175,8 @@ void main() {
testWidgets('Reset dialog confirms and resets data', (tester) async {
await tester.runAsync(
() => StorageService.instance
.setExerciseWeight(workoutA.first.name, 99.0),
() =>
StorageService.instance.setExerciseWeight(workoutA.first.name, 99.0),
);
await _pump(tester, _wrap());
@ -273,11 +279,10 @@ void main() {
await tester.tap(find.text('Connect GitHub'));
await _pumpUntil(
tester,
() =>
find
.textContaining('Could not start device flow')
.evaluate()
.isNotEmpty,
() => find
.textContaining('Could not start device flow')
.evaluate()
.isNotEmpty,
);
expect(
@ -328,11 +333,10 @@ void main() {
await _pumpUntil(
tester,
() =>
find
.textContaining('Connected and verified via GitHub')
.evaluate()
.isNotEmpty,
() => find
.textContaining('Connected and verified via GitHub')
.evaluate()
.isNotEmpty,
);
expect(
@ -437,9 +441,12 @@ void main() {
final failLabel = find.text('↓ Decrease after N failures').first;
await tester.ensureVisible(failLabel);
await tester.pumpAndSettle();
final failRow =
find.ancestor(of: failLabel, matching: find.byType(Row)).first;
final failFour = find.descendant(of: failRow, matching: find.text('4')).first;
final failRow = find
.ancestor(of: failLabel, matching: find.byType(Row))
.first;
final failFour = find
.descendant(of: failRow, matching: find.text('4'))
.first;
await tester.ensureVisible(failFour);
await tester.pumpAndSettle();
await tester.runAsync(() async {
@ -513,8 +520,10 @@ void main() {
await tester.tap(find.text('Connect GitHub'));
await _pumpUntil(
tester,
() =>
find.textContaining('could not save the token').evaluate().isNotEmpty,
() => find
.textContaining('could not save the token')
.evaluate()
.isNotEmpty,
);
expect(
find.textContaining('could not save the token'),
@ -606,10 +615,7 @@ void main() {
await _pumpUntil(
tester,
() =>
find
.textContaining('Connected and verified')
.evaluate()
.isNotEmpty,
find.textContaining('Connected and verified').evaluate().isNotEmpty,
);
expect(find.textContaining('Connected and verified'), findsOneWidget);
});

View File

@ -5,6 +5,7 @@ import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/workout_plan.dart';
import 'package:workout_app/screens/workout_screen.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/break_banner.dart';
import 'package:workout_app/widgets/exercise_tile.dart';
import 'package:workout_app/widgets/rep_circle.dart';
@ -15,18 +16,18 @@ import '../fake_secure_storage.dart';
/// A saved state with every set of the two [_exercises] tapped as done.
Map<String, dynamic> _completeSaved() => {
'workoutType': 'A',
'startTimeMs': DateTime.now().millisecondsSinceEpoch,
'tapped': [
[true, true, true],
[true, true, true],
],
'doneReps': [
[5, 5, 5],
[5, 5, 5],
],
'warmupTapped': [false, false],
};
'workoutType': 'A',
'startTimeMs': DateTime.now().millisecondsSinceEpoch,
'tapped': [
[true, true, true],
[true, true, true],
],
'doneReps': [
[5, 5, 5],
[5, 5, 5],
],
'warmupTapped': [false, false],
};
const _exercises = [
Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0),
@ -37,14 +38,14 @@ Widget _wrap({
String type = 'A',
List<Exercise> exercises = _exercises,
Map<String, dynamic>? savedState,
}) =>
MaterialApp(
home: WorkoutScreen(
workoutType: type,
exercises: exercises,
savedState: savedState,
),
);
}) => MaterialApp(
theme: buildAppTheme(),
home: WorkoutScreen(
workoutType: type,
exercises: exercises,
savedState: savedState,
),
);
void main() {
setUpAll(() {
@ -89,7 +90,9 @@ void main() {
expect(find.text('Finish'), findsOneWidget);
});
testWidgets('Finish button is disabled when not all sets done', (tester) async {
testWidgets('Finish button is disabled when not all sets done', (
tester,
) async {
await _pump(tester, _wrap());
final finishButton = tester.widget<TextButton>(
find.widgetWithText(TextButton, 'Finish'),
@ -121,8 +124,9 @@ void main() {
final now = DateTime.now();
final saved = {
'workoutType': 'A',
'startTimeMs':
now.subtract(const Duration(minutes: 10)).millisecondsSinceEpoch,
'startTimeMs': now
.subtract(const Duration(minutes: 10))
.millisecondsSinceEpoch,
'tapped': [
[true, true, true],
[true, true, true],
@ -200,7 +204,9 @@ void main() {
await tester.pump();
}
testWidgets('tapping a set starts a break which Skip cancels', (tester) async {
testWidgets('tapping a set starts a break which Skip cancels', (
tester,
) async {
await _pump(tester, _wrap());
await _tapReal(tester, find.byType(RepCircle).first);
expect(find.byType(BreakBanner), findsOneWidget);
@ -217,8 +223,9 @@ void main() {
expect(find.textContaining('Warmup rest'), findsOneWidget);
});
testWidgets('re-tapping a set decrements reps and recomputes the break',
(tester) async {
testWidgets('re-tapping a set decrements reps and recomputes the break', (
tester,
) async {
await _pump(tester, _wrap());
final circle = find.byType(RepCircle).first;
await _tapReal(tester, circle); // done=5 -> success break
@ -228,8 +235,9 @@ void main() {
expect(find.textContaining('keep going'), findsOneWidget);
});
testWidgets('long-pressing a set resets it and cancels its break',
(tester) async {
testWidgets('long-pressing a set resets it and cancels its break', (
tester,
) async {
await _pump(tester, _wrap());
final circle = find.byType(RepCircle).first;
await _tapReal(tester, circle);
@ -239,8 +247,9 @@ void main() {
expect(find.byType(BreakBanner), findsNothing);
});
testWidgets('finishing a completed workout saves and shows the summary',
(tester) async {
testWidgets('finishing a completed workout saves and shows the summary', (
tester,
) async {
await _pump(tester, _wrap(savedState: _completeSaved()));
// Drive the whole flow on the real loop: _confirmFinish's showDialog await
@ -299,7 +308,9 @@ void main() {
expect(session, isNull); // reset cleared the persisted session
});
testWidgets('changing a threshold in the workout persists it', (tester) async {
testWidgets('changing a threshold in the workout persists it', (
tester,
) async {
// A real exercise has a progression-state row, so _onThresholdChanged's
// state-update branch runs (and the write actually lands).
final name = workoutA.first.name;

View File

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/ui/theme.dart';
void main() {
group('buildAppTheme', () {
test('uses the shared palette and 16px body floor', () {
final theme = buildAppTheme();
expect(theme.colorScheme.brightness, Brightness.dark);
expect(theme.colorScheme.primary, const Color(0xFFB8862E));
expect(theme.colorScheme.onPrimary, const Color(0xFF211D1B));
expect(theme.textTheme.bodyLarge!.fontSize, AppTextSize.body);
expect(theme.textTheme.bodyMedium!.fontSize, AppTextSize.body);
});
test('registers AppStatusColors', () {
final theme = buildAppTheme();
expect(theme.extension<AppStatusColors>(), AppStatusColors.dark);
});
});
group('AppStatusColors', () {
const colors = AppStatusColors.dark;
test('copyWith overrides only the given fields', () {
final overridden = colors.copyWith(success: Colors.red);
expect(overridden.success, Colors.red);
expect(overridden.warning, colors.warning);
});
test('copyWith with no args returns equivalent values', () {
final same = colors.copyWith();
expect(same.success, colors.success);
expect(same.warning, colors.warning);
});
test('lerp at t=0 returns this', () {
const other = AppStatusColors(success: Colors.red, warning: Colors.blue);
final result = colors.lerp(other, 0);
expect(result.success, colors.success);
expect(result.warning, colors.warning);
});
test('lerp at t=1 matches Color.lerp at t=1', () {
const other = AppStatusColors(success: Colors.red, warning: Colors.blue);
final result = colors.lerp(other, 1);
expect(result.success, Color.lerp(colors.success, other.success, 1));
expect(result.warning, Color.lerp(colors.warning, other.warning, 1));
});
test('lerp with a non-AppStatusColors other returns this unchanged', () {
final result = colors.lerp(null, 0.5);
expect(result, same(colors));
});
});
}

View File

@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/break_banner.dart';
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
Widget _wrap(Widget child) => MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: child),
);
void main() {
group('BreakBanner', () {

View File

@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/calendar_widget.dart';
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
Widget _wrap(Widget child) => MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: child),
);
void main() {
group('WorkoutCalendar', () {

View File

@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/widgets/exercise_tile.dart';
import 'package:workout_app/widgets/rep_circle.dart';
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
Widget _wrap(Widget child) => MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: child),
);
const _exercise = Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0);
@ -18,19 +22,18 @@ ExerciseTile _tile({
void Function(int)? onLongPressCircle,
VoidCallback? onTapWarmup,
void Function(int, int)? onThresholdChanged,
}) =>
ExerciseTile(
exercise: _exercise,
tapped: tapped ?? [false, false, false],
doneReps: doneReps ?? [5, 5, 5],
warmupTapped: warmupTapped,
successThreshold: successThreshold,
failThreshold: failThreshold,
onTapCircle: onTapCircle ?? (_) {},
onLongPressCircle: onLongPressCircle ?? (_) {},
onTapWarmup: onTapWarmup ?? () {},
onThresholdChanged: onThresholdChanged ?? (_, __) {},
);
}) => ExerciseTile(
exercise: _exercise,
tapped: tapped ?? [false, false, false],
doneReps: doneReps ?? [5, 5, 5],
warmupTapped: warmupTapped,
successThreshold: successThreshold,
failThreshold: failThreshold,
onTapCircle: onTapCircle ?? (_) {},
onLongPressCircle: onLongPressCircle ?? (_) {},
onTapWarmup: onTapWarmup ?? () {},
onThresholdChanged: onThresholdChanged ?? (_, __) {},
);
void main() {
group('ExerciseTile', () {
@ -43,7 +46,10 @@ void main() {
testWidgets('shows warmup weight', (tester) async {
await tester.pumpWidget(_wrap(_tile()));
// warmupWeight for 20kg squat = 10kg (50%)
expect(find.textContaining('${_exercise.warmupWeight}kg'), findsOneWidget);
expect(
find.textContaining('${_exercise.warmupWeight}kg'),
findsOneWidget,
);
});
testWidgets('calls onTapCircle when set circle tapped', (tester) async {
@ -87,7 +93,9 @@ void main() {
expect(find.byType(ExerciseTile), findsOneWidget);
});
testWidgets('header is red when all sets tapped but some failed', (tester) async {
testWidgets('header is red when all sets tapped but some failed', (
tester,
) async {
await tester.pumpWidget(
_wrap(
_tile(

View File

@ -1,12 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/rep_circle.dart';
Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
Widget _wrap(Widget child) => MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: child),
);
void main() {
group('RepCircle states', () {
testWidgets('neutral shows target reps and white background', (tester) async {
testWidgets('neutral shows target reps and white background', (
tester,
) async {
var tapped = false;
await tester.pumpWidget(
_wrap(
@ -25,7 +31,9 @@ void main() {
expect(tapped, isTrue);
});
testWidgets('success state (tapped, doneReps == targetReps)', (tester) async {
testWidgets('success state (tapped, doneReps == targetReps)', (
tester,
) async {
await tester.pumpWidget(
_wrap(
RepCircle(
@ -40,7 +48,9 @@ void main() {
expect(find.text('5'), findsOneWidget);
});
testWidgets('partial state (tapped, 0 < doneReps < targetReps)', (tester) async {
testWidgets('partial state (tapped, 0 < doneReps < targetReps)', (
tester,
) async {
await tester.pumpWidget(
_wrap(
RepCircle(
@ -90,7 +100,10 @@ void main() {
group('RepCircleState enum', () {
test('all values are distinct', () {
expect(RepCircleState.values.toSet().length, RepCircleState.values.length);
expect(
RepCircleState.values.toSet().length,
RepCircleState.values.length,
);
});
});
}

View File

@ -5,9 +5,13 @@ import 'package:workout_app/models/exercise_result.dart';
import 'package:workout_app/models/set_result.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/sync_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/workout_summary_dialog.dart';
WorkoutSession _session({bool allSucceeded = true, Duration duration = const Duration(minutes: 45, seconds: 30)}) {
WorkoutSession _session({
bool allSucceeded = true,
Duration duration = const Duration(minutes: 45, seconds: 30),
}) {
final start = DateTime(2024, 6, 1, 9);
final end = start.add(duration);
return WorkoutSession(
@ -32,9 +36,12 @@ WorkoutSession _session({bool allSucceeded = true, Duration duration = const Dur
void main() {
group('WorkoutSummaryDialog', () {
testWidgets('shows "Workout Complete!" when fully succeeded', (tester) async {
testWidgets('shows "Workout Complete!" when fully succeeded', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: WorkoutSummaryDialog(
session: _session(),
syncResult: const SyncResult(
@ -47,9 +54,12 @@ void main() {
expect(find.textContaining('Workout Complete'), findsOneWidget);
});
testWidgets('shows "Workout Done" when not fully succeeded', (tester) async {
testWidgets('shows "Workout Done" when not fully succeeded', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: WorkoutSummaryDialog(
session: _session(allSucceeded: false),
syncResult: const SyncResult(
@ -63,9 +73,12 @@ void main() {
expect(find.text('Workout Done'), findsOneWidget);
});
testWidgets('shows duration in mm m ss s format for sub-hour workout', (tester) async {
testWidgets('shows duration in mm m ss s format for sub-hour workout', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: WorkoutSummaryDialog(
session: _session(),
syncResult: const SyncResult(success: true, path: '/p'),
@ -78,8 +91,11 @@ void main() {
testWidgets('shows hours in duration for long workouts', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: WorkoutSummaryDialog(
session: _session(duration: const Duration(hours: 1, minutes: 5, seconds: 3)),
session: _session(
duration: const Duration(hours: 1, minutes: 5, seconds: 3),
),
syncResult: const SyncResult(success: true, path: '/p'),
),
),
@ -87,9 +103,12 @@ void main() {
expect(find.textContaining('1h'), findsOneWidget);
});
testWidgets('shows exercise name with check mark on success', (tester) async {
testWidgets('shows exercise name with check mark on success', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: WorkoutSummaryDialog(
session: _session(),
syncResult: const SyncResult(success: true, path: '/p'),
@ -102,9 +121,14 @@ void main() {
testWidgets('shows exercise name with cross on failure', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: WorkoutSummaryDialog(
session: _session(allSucceeded: false),
syncResult: const SyncResult(success: false, path: null, error: 'err'),
syncResult: const SyncResult(
success: false,
path: null,
error: 'err',
),
),
),
);
@ -114,6 +138,7 @@ void main() {
testWidgets('shows saved path on sync success', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: WorkoutSummaryDialog(
session: _session(),
syncResult: const SyncResult(
@ -132,6 +157,7 @@ void main() {
testWidgets('shows error message on sync failure', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: WorkoutSummaryDialog(
session: _session(allSucceeded: false),
syncResult: const SyncResult(
@ -151,6 +177,7 @@ void main() {
testWidgets('"Back to Home" button pops to first route', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: WorkoutSummaryDialog(
session: _session(),