Split settings_screen.dart's widgets into part files (771 -> 503)

settings_screen_rows.dart        section header, reps row, weight row
  settings_screen_thresholds.dart  stepper button, threshold card and row

Still over the cap: the remaining 503 lines are the State class, whose ~190
line build() would have to be broken up to go further. Two routes were tried
and reverted rather than shipped half-working:

- an extension holding the async actions -- rejected by the analyzer, because
  setState is @protected and cannot be called from an extension. Worth
  recording: it rules out moving any State method that calls setState.
- an extension exposing the SYNC/OFFLINE BACKUP rows as a widget-list getter.
  That one is viable (those rows never call setState) but slicing a balanced
  chunk out of a nested list literal is not a mechanical move, and this screen
  ships to the phone.

402 Dart tests pass; coverage 100%; dart analyze clean.

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:15:55 +02:00
parent d418994f6f
commit 338ef5ed9b
3 changed files with 283 additions and 271 deletions

View File

@ -22,6 +22,9 @@ import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
part 'settings_screen_rows.dart';
part 'settings_screen_thresholds.dart';
/// Screen for editing per-exercise thresholds and manual weight overrides.
///
/// No [BackupSlot] is wired into the shared Sync settings screen: unlike the
@ -498,274 +501,3 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
letterSpacing: 1.4,
),
);
}
}
class _RepsRow extends StatelessWidget {
const _RepsRow({
required this.name,
required this.reps,
required this.onChanged,
});
final String name;
final int reps;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Expanded(
child: Text(
name,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
_StepperButton(
icon: Icons.remove,
onTap: () => onChanged((reps - 1).clamp(1, 999)),
),
SizedBox(
width: 72,
child: Text(
'$reps reps',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.label,
fontWeight: FontWeight.bold,
),
),
),
_StepperButton(
icon: Icons.add,
onTap: () => onChanged((reps + 1).clamp(1, 999)),
),
],
),
);
}
}
class _WeightRow extends StatelessWidget {
const _WeightRow({
required this.name,
required this.weight,
required this.onChanged,
});
final String name;
final double weight;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Expanded(
child: Text(
name,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
_StepperButton(
icon: Icons.remove,
onTap: () => onChanged(
(weight - kWeightIncrement).clamp(0.0, 999.0),
),
),
// Fixed-width container supports up to "999.9kg" (7 chars).
SizedBox(
width: 72,
child: Text(
'${weight}kg',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.label,
fontWeight: FontWeight.bold,
),
),
),
_StepperButton(
icon: Icons.add,
onTap: () => onChanged(weight + kWeightIncrement),
),
],
),
);
}
}
class _StepperButton extends StatelessWidget {
const _StepperButton({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Icon(icon, color: colorScheme.onSurface, size: 18),
),
);
}
}
class _ExerciseThresholdCard extends StatelessWidget {
const _ExerciseThresholdCard({
required this.name,
required this.successThreshold,
required this.failThreshold,
required this.onSuccessChanged,
required this.onFailChanged,
});
final String name;
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>()!;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.label,
),
),
const SizedBox(height: 10),
_ThresholdRow(
label: '↑ Increase after N successes',
value: successThreshold,
color: status.success,
onChanged: onSuccessChanged,
),
const SizedBox(height: 6),
_ThresholdRow(
label: '↓ Decrease after N failures',
value: failThreshold,
color: colorScheme.error,
onChanged: onFailChanged,
),
],
),
);
}
}
class _ThresholdRow extends StatelessWidget {
const _ThresholdRow({
required this.label,
required this.value,
required this.color,
required this.onChanged,
});
final String label;
final int value;
final Color color;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
),
const SizedBox(width: 8),
for (int i = 1; i <= 5; i++)
Padding(
padding: const EdgeInsets.only(left: 4),
child: GestureDetector(
onTap: () => onChanged(i),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == value
? color
: colorScheme.surfaceContainerHighest,
),
alignment: Alignment.center,
child: Text(
'$i',
style: TextStyle(
// on-fill on the selected (filled) circle.
color: i == value
? colorScheme.onPrimary
: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.label,
),
),
),
),
),
],
);
}
}

View File

@ -0,0 +1,157 @@
// Section header and the reps/weight adjustment rows.
//
// `part` files: these widgets are library-private and stay that way.
// See history_screen_charts.dart for the full reasoning.
part of 'settings_screen.dart';
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
letterSpacing: 1.4,
),
);
}
}
class _RepsRow extends StatelessWidget {
const _RepsRow({
required this.name,
required this.reps,
required this.onChanged,
});
final String name;
final int reps;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Expanded(
child: Text(
name,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
_StepperButton(
icon: Icons.remove,
onTap: () => onChanged((reps - 1).clamp(1, 999)),
),
SizedBox(
width: 72,
child: Text(
'$reps reps',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.label,
fontWeight: FontWeight.bold,
),
),
),
_StepperButton(
icon: Icons.add,
onTap: () => onChanged((reps + 1).clamp(1, 999)),
),
],
),
);
}
}
class _WeightRow extends StatelessWidget {
const _WeightRow({
required this.name,
required this.weight,
required this.onChanged,
});
final String name;
final double weight;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Expanded(
child: Text(
name,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.label,
),
),
),
_StepperButton(
icon: Icons.remove,
onTap: () => onChanged(
(weight - kWeightIncrement).clamp(0.0, 999.0),
),
),
// Fixed-width container supports up to "999.9kg" (7 chars).
SizedBox(
width: 72,
child: Text(
'${weight}kg',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: AppTextSize.label,
fontWeight: FontWeight.bold,
),
),
),
_StepperButton(
icon: Icons.add,
onTap: () => onChanged(weight + kWeightIncrement),
),
],
),
);
}
}
class _StepperButton extends StatelessWidget {
const _StepperButton({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Icon(icon, color: colorScheme.onSurface, size: 18),
),
);
}
}

View File

@ -0,0 +1,123 @@
// The stepper button and the per-exercise threshold card.
//
// See settings_screen_rows.dart for why these are `part` files.
part of 'settings_screen.dart';
class _ExerciseThresholdCard extends StatelessWidget {
const _ExerciseThresholdCard({
required this.name,
required this.successThreshold,
required this.failThreshold,
required this.onSuccessChanged,
required this.onFailChanged,
});
final String name;
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>()!;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.label,
),
),
const SizedBox(height: 10),
_ThresholdRow(
label: '↑ Increase after N successes',
value: successThreshold,
color: status.success,
onChanged: onSuccessChanged,
),
const SizedBox(height: 6),
_ThresholdRow(
label: '↓ Decrease after N failures',
value: failThreshold,
color: colorScheme.error,
onChanged: onFailChanged,
),
],
),
);
}
}
class _ThresholdRow extends StatelessWidget {
const _ThresholdRow({
required this.label,
required this.value,
required this.color,
required this.onChanged,
});
final String label;
final int value;
final Color color;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
),
const SizedBox(width: 8),
for (int i = 1; i <= 5; i++)
Padding(
padding: const EdgeInsets.only(left: 4),
child: GestureDetector(
onTap: () => onChanged(i),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == value
? color
: colorScheme.surfaceContainerHighest,
),
alignment: Alignment.center,
child: Text(
'$i',
style: TextStyle(
// on-fill on the selected (filled) circle.
color: i == value
? colorScheme.onPrimary
: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.label,
),
),
),
),
),
],
);
}
}