mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 14:07:41 +01:00
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:
parent
9184a6bc33
commit
5977475021
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user