mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 16:53:58 +01:00
workout_app: warm-tint the unified-design-system palette
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:
parent
0c6f6957a8
commit
323e3cd02a
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
164
stronglift_replacement/workout_app/lib/ui/theme.dart
Normal file
164
stronglift_replacement/workout_app/lib/ui/theme.dart
Normal 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;
|
||||
}
|
||||
@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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>[])),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
57
stronglift_replacement/workout_app/test/ui/theme_test.dart
Normal file
57
stronglift_replacement/workout_app/test/ui/theme_test.dart
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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', () {
|
||||
|
||||
@ -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', () {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user