Split the private widgets out of three more Dart screens

exercise_tile.dart        319 -> 144  + exercise_tile_rows.dart
  github_mirror_screen.dart 441 -> 247  + github_mirror_screen_widgets.dart
  home_screen.dart          383 -> 260  + home_screen_cards.dart

Same `part` pattern as history_screen: the private StatelessWidgets move, the
State stays. home_screen lands 10 over -- its remaining methods all call
setState, which an extension cannot, and its build() is one cohesive tree.

402 Dart tests; dart analyze clean; coverage gate passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 21:39:57 +02:00
parent 9184a6bc33
commit 5977475021
6 changed files with 520 additions and 498 deletions

View File

@ -21,6 +21,8 @@ import 'package:workout_app/services/github_device_auth.dart';
import 'package:workout_app/services/sync_settings.dart';
import 'package:workout_app/ui/theme.dart';
part 'github_mirror_screen_widgets.dart';
/// How to style a [_SyncStatusBadge].
enum _SyncStatusKind { success, pending, error }
@ -243,199 +245,3 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
}
/// A visible, colored status pill for the GitHub sync connection state.
class _SyncStatusBadge extends StatelessWidget {
const _SyncStatusBadge({required this.text, required this.kind});
final String text;
final _SyncStatusKind kind;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final color = switch (kind) {
_SyncStatusKind.success => status.success,
_SyncStatusKind.error => colorScheme.error,
_SyncStatusKind.pending => colorScheme.onSurfaceVariant,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
children: [
if (kind == _SyncStatusKind.pending)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(
kind == _SyncStatusKind.success
? Icons.check_circle
: Icons.error,
color: color,
size: 16,
),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(
color: color,
fontSize: AppTextSize.label,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
class _SyncTokenField extends StatelessWidget {
const _SyncTokenField({required this.controller, required this.onSave});
final TextEditingController controller;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: controller,
obscureText: true,
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: colorScheme.onSurfaceVariant),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
),
const SizedBox(width: 8),
ElevatedButton(onPressed: onSave, child: const Text('Save')),
],
);
}
}
/// Dialog shown during the device flow: displays the user code, opens the
/// verification page, and polls until authorized -- popping the token (or
/// null if cancelled / failed).
class _DeviceCodeDialog extends StatefulWidget {
const _DeviceCodeDialog({required this.device, required this.auth});
final DeviceCodeResponse device;
final GitHubDeviceAuth auth;
@override
State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
}
class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
String? _error;
@override
void initState() {
super.initState();
unawaited(_poll());
}
Future<void> _poll() async {
try {
final token = await widget.auth.pollForToken(widget.device);
if (mounted) Navigator.of(context).pop(token);
} on Exception catch (e) {
if (mounted) setState(() => _error = '$e');
}
}
Future<void> _openPage() async {
await Clipboard.setData(ClipboardData(text: widget.device.userCode));
await launchUrl(
Uri.parse(widget.device.verificationUri),
mode: LaunchMode.externalApplication,
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Authorize on GitHub',
style: TextStyle(color: colorScheme.onSurface),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Enter this code on GitHub:',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
SelectableText(
widget.device.userCode,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.title,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (_error == null)
Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Waiting for authorization…',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
],
)
else
Text(_error!, style: TextStyle(color: colorScheme.error)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancel',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
FilledButton.icon(
onPressed: _openPage,
icon: const Icon(Icons.open_in_new),
label: const Text('Open GitHub & copy code'),
),
],
);
}
}

View File

@ -0,0 +1,202 @@
// Status badge, token field and the device-code dialog.
//
// `part` file: these widgets are library-private and stay that way.
// See history_screen_charts.dart for the full reasoning.
part of 'github_mirror_screen.dart';
class _SyncStatusBadge extends StatelessWidget {
const _SyncStatusBadge({required this.text, required this.kind});
final String text;
final _SyncStatusKind kind;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final color = switch (kind) {
_SyncStatusKind.success => status.success,
_SyncStatusKind.error => colorScheme.error,
_SyncStatusKind.pending => colorScheme.onSurfaceVariant,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
children: [
if (kind == _SyncStatusKind.pending)
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Icon(
kind == _SyncStatusKind.success
? Icons.check_circle
: Icons.error,
color: color,
size: 16,
),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(
color: color,
fontSize: AppTextSize.label,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
class _SyncTokenField extends StatelessWidget {
const _SyncTokenField({required this.controller, required this.onSave});
final TextEditingController controller;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: controller,
obscureText: true,
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: colorScheme.onSurfaceVariant),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
),
const SizedBox(width: 8),
ElevatedButton(onPressed: onSave, child: const Text('Save')),
],
);
}
}
/// Dialog shown during the device flow: displays the user code, opens the
/// verification page, and polls until authorized -- popping the token (or
/// null if cancelled / failed).
class _DeviceCodeDialog extends StatefulWidget {
const _DeviceCodeDialog({required this.device, required this.auth});
final DeviceCodeResponse device;
final GitHubDeviceAuth auth;
@override
State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
}
class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
String? _error;
@override
void initState() {
super.initState();
unawaited(_poll());
}
Future<void> _poll() async {
try {
final token = await widget.auth.pollForToken(widget.device);
if (mounted) Navigator.of(context).pop(token);
} on Exception catch (e) {
if (mounted) setState(() => _error = '$e');
}
}
Future<void> _openPage() async {
await Clipboard.setData(ClipboardData(text: widget.device.userCode));
await launchUrl(
Uri.parse(widget.device.verificationUri),
mode: LaunchMode.externalApplication,
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Authorize on GitHub',
style: TextStyle(color: colorScheme.onSurface),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Enter this code on GitHub:',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
SelectableText(
widget.device.userCode,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.title,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
if (_error == null)
Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Waiting for authorization…',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
],
)
else
Text(_error!, style: TextStyle(color: colorScheme.error)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Cancel',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
FilledButton.icon(
onPressed: _openPage,
icon: const Icon(Icons.open_in_new),
label: const Text('Open GitHub & copy code'),
),
],
);
}
}

View File

@ -14,6 +14,8 @@ import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/sync_status_card.dart';
part 'home_screen_cards.dart';
/// Home screen: auto-resumes active sessions and shows done-today status.
class HomeScreen extends StatefulWidget {
/// Creates a [HomeScreen].
@ -256,128 +258,3 @@ class _HomeScreenState extends State<HomeScreen> {
}
// ── Sub-widgets ──────────────────────────────────────────────────────────────
class _WorkoutCard extends StatelessWidget {
const _WorkoutCard({
required this.type,
required this.exercises,
required this.doneToday,
required this.hasActiveSession,
required this.onStart,
required this.onResume,
});
final String type;
final List<Exercise> exercises;
final bool doneToday;
final bool hasActiveSession;
final VoidCallback onStart;
final VoidCallback onResume;
@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: colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (doneToday && !hasActiveSession) ...[
Row(
children: [
Icon(Icons.check_circle, color: status.success, size: 18),
const SizedBox(width: 8),
Text(
'Done for today!',
style: TextStyle(
color: status.success,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.body,
),
),
],
),
const SizedBox(height: 6),
Text(
'Next: Workout $type — tomorrow',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
] else ...[
Text(
hasActiveSession
? 'Workout $type in progress'
: 'Next: Workout $type',
style: TextStyle(
color: hasActiveSession
? status.warning
: colorScheme.onSurface,
fontSize: AppTextSize.subtitle,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(height: 10),
...exercises.map(
(e) => Text(
'${e.name} ${e.sets}×${e.reps}×${e.weight}kg',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
const SizedBox(height: 14),
if (hasActiveSession)
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: status.warning,
padding: buttonPadding,
),
onPressed: onResume,
child: Text(
'Resume Workout',
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
),
)
else if (!doneToday)
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
padding: buttonPadding,
),
onPressed: onStart,
child: Text(
'Start Workout $type',
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,130 @@
// The workout and status cards on the home screen.
//
// `part` file: these widgets are library-private and stay that way.
// See history_screen_charts.dart for the full reasoning.
part of 'home_screen.dart';
class _WorkoutCard extends StatelessWidget {
const _WorkoutCard({
required this.type,
required this.exercises,
required this.doneToday,
required this.hasActiveSession,
required this.onStart,
required this.onResume,
});
final String type;
final List<Exercise> exercises;
final bool doneToday;
final bool hasActiveSession;
final VoidCallback onStart;
final VoidCallback onResume;
@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: colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (doneToday && !hasActiveSession) ...[
Row(
children: [
Icon(Icons.check_circle, color: status.success, size: 18),
const SizedBox(width: 8),
Text(
'Done for today!',
style: TextStyle(
color: status.success,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.body,
),
),
],
),
const SizedBox(height: 6),
Text(
'Next: Workout $type — tomorrow',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
] else ...[
Text(
hasActiveSession
? 'Workout $type in progress'
: 'Next: Workout $type',
style: TextStyle(
color: hasActiveSession
? status.warning
: colorScheme.onSurface,
fontSize: AppTextSize.subtitle,
fontWeight: FontWeight.bold,
),
),
],
const SizedBox(height: 10),
...exercises.map(
(e) => Text(
'${e.name} ${e.sets}×${e.reps}×${e.weight}kg',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
const SizedBox(height: 14),
if (hasActiveSession)
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: status.warning,
padding: buttonPadding,
),
onPressed: onResume,
child: Text(
'Resume Workout',
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
),
)
else if (!doneToday)
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
padding: buttonPadding,
),
onPressed: onStart,
child: Text(
'Start Workout $type',
style: TextStyle(
color: colorScheme.onPrimary,
fontSize: AppTextSize.body,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}

View File

@ -6,6 +6,8 @@ import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/rep_circle.dart';
part 'exercise_tile_rows.dart';
/// Card widget displaying warmup and working-set rep circles for one exercise.
class ExerciseTile extends StatelessWidget {
/// Creates an [ExerciseTile].
@ -140,180 +142,3 @@ class ExerciseTile extends StatelessWidget {
);
}
}
class _ThresholdRow extends StatelessWidget {
const _ThresholdRow({
required this.successThreshold,
required this.failThreshold,
required this.onSuccessChanged,
required this.onFailChanged,
});
final int successThreshold;
final int failThreshold;
final ValueChanged<int> onSuccessChanged;
final ValueChanged<int> onFailChanged;
@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: [
Icon(Icons.trending_up, size: 13, color: status.success),
const SizedBox(width: 4),
Text('after', style: captionStyle),
const SizedBox(width: 6),
_MiniStepper(
value: successThreshold,
onChanged: onSuccessChanged,
),
const SizedBox(width: 4),
Text('↑', style: captionStyle),
const Spacer(),
Icon(Icons.trending_down, size: 13, color: colorScheme.error),
const SizedBox(width: 4),
Text('after', style: captionStyle),
const SizedBox(width: 6),
_MiniStepper(
value: failThreshold,
onChanged: onFailChanged,
),
const SizedBox(width: 4),
Text('↓', style: captionStyle),
],
);
}
}
class _MiniStepper extends StatelessWidget {
const _MiniStepper({required this.value, required this.onChanged});
final int value;
final ValueChanged<int> onChanged;
static const _min = 1;
static const _max = 5;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_btn(
context,
Icons.remove,
value > _min ? () => onChanged(value - 1) : null,
),
SizedBox(
width: 22,
child: Text(
'$value',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.caption,
),
),
),
_btn(
context,
Icons.add,
value < _max ? () => onChanged(value + 1) : null,
),
],
);
}
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,
),
),
);
}
}
class _WarmupRow extends StatelessWidget {
const _WarmupRow({
required this.warmupWeight,
required this.tapped,
required this.onTap,
});
final double warmupWeight;
final bool tapped;
final VoidCallback onTap;
@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: [
Text('Warmup 1×5×', style: mutedStyle),
Text('${warmupWeight}kg', style: mutedStyle),
const SizedBox(width: 10),
GestureDetector(
onTap: tapped ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 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 ? status.success : colorScheme.outline,
width: 2,
),
),
alignment: Alignment.center,
child: Icon(
tapped ? Icons.check : Icons.fitness_center,
// on-fill on the filled circle; muted on the empty outline one.
color: tapped
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
size: 16,
),
),
),
const SizedBox(width: 6),
Text(
tapped ? 'done' : 'optional',
style: TextStyle(
color: tapped ? status.success : colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
);
}
}

View File

@ -0,0 +1,182 @@
// Threshold row, mini stepper and warm-up row for ExerciseTile.
//
// `part` file: these widgets are library-private and stay that way.
// See history_screen_charts.dart for the full reasoning.
part of 'exercise_tile.dart';
class _ThresholdRow extends StatelessWidget {
const _ThresholdRow({
required this.successThreshold,
required this.failThreshold,
required this.onSuccessChanged,
required this.onFailChanged,
});
final int successThreshold;
final int failThreshold;
final ValueChanged<int> onSuccessChanged;
final ValueChanged<int> onFailChanged;
@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: [
Icon(Icons.trending_up, size: 13, color: status.success),
const SizedBox(width: 4),
Text('after', style: captionStyle),
const SizedBox(width: 6),
_MiniStepper(
value: successThreshold,
onChanged: onSuccessChanged,
),
const SizedBox(width: 4),
Text('↑', style: captionStyle),
const Spacer(),
Icon(Icons.trending_down, size: 13, color: colorScheme.error),
const SizedBox(width: 4),
Text('after', style: captionStyle),
const SizedBox(width: 6),
_MiniStepper(
value: failThreshold,
onChanged: onFailChanged,
),
const SizedBox(width: 4),
Text('↓', style: captionStyle),
],
);
}
}
class _MiniStepper extends StatelessWidget {
const _MiniStepper({required this.value, required this.onChanged});
final int value;
final ValueChanged<int> onChanged;
static const _min = 1;
static const _max = 5;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_btn(
context,
Icons.remove,
value > _min ? () => onChanged(value - 1) : null,
),
SizedBox(
width: 22,
child: Text(
'$value',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.caption,
),
),
),
_btn(
context,
Icons.add,
value < _max ? () => onChanged(value + 1) : null,
),
],
);
}
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,
),
),
);
}
}
class _WarmupRow extends StatelessWidget {
const _WarmupRow({
required this.warmupWeight,
required this.tapped,
required this.onTap,
});
final double warmupWeight;
final bool tapped;
final VoidCallback onTap;
@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: [
Text('Warmup 1×5×', style: mutedStyle),
Text('${warmupWeight}kg', style: mutedStyle),
const SizedBox(width: 10),
GestureDetector(
onTap: tapped ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 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 ? status.success : colorScheme.outline,
width: 2,
),
),
alignment: Alignment.center,
child: Icon(
tapped ? Icons.check : Icons.fitness_center,
// on-fill on the filled circle; muted on the empty outline one.
color: tapped
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
size: 16,
),
),
),
const SizedBox(width: 6),
Text(
tapped ? 'done' : 'optional',
style: TextStyle(
color: tapped ? status.success : colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
);
}
}